mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Remove navigation guide modal and reopen control
The four-step coachmark over the top tabs was a tour pretending to be guidance: each step duplicated the tab title in one sentence, and the Reopen control on /settings/system-general spawned a centered panel with no spotlight target because the tabs only exist on dashboard routes. Delete the modal, the localStorage dismissal key, the reopen event, the Reopen row in General settings, and the matching guardrails so the shared-primitives tests stop pinning the deleted owner split. Drop the WhatsNew dismissal helpers and addInitScript bypasses from the integration suite, and the dedicated tour test in 19-telemetry-disclosure.
This commit is contained in:
parent
0895916283
commit
2f8e5184bd
31 changed files with 9 additions and 1016 deletions
|
|
@ -1062,7 +1062,6 @@ and existing alert-history shells instead of introducing VMware-only labels,
|
|||
badges, or panel copy just because the underlying signal came from vSphere.
|
||||
That same shared settings and modal boundary now also owns the public usage-data
|
||||
vocabulary. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`,
|
||||
`frontend-modern/src/components/shared/whatsNewModalModel.ts`,
|
||||
`frontend-modern/src/components/Settings/useSystemSettingsState.ts`, and
|
||||
`frontend-modern/src/utils/systemSettingsPresentation.ts` must present one
|
||||
explicit `Usage data and privacy` model centered on `Anonymous outbound
|
||||
|
|
@ -1725,60 +1724,10 @@ 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, step progression, spotlight target
|
||||
resolution, direct stop selection, and overlay placement/runtime behavior, and
|
||||
`frontend-modern/src/components/shared/whatsNewModalModel.ts` owns the feature
|
||||
tour catalog, telemetry copy, labels, and canonical docs/privacy links. Future
|
||||
what's-new work should extend those owners instead of pushing dismissal state,
|
||||
spotlight runtime, product copy, or external links back into the shared shell.
|
||||
The v6 welcome surface is one guided spotlight tour, not a modal plus a second
|
||||
dashboard-only migration hint: it must dim the live app, glow the real
|
||||
primary-navigation target being described, and keep route-orientation copy on
|
||||
the existing welcome flow instead of layering a duplicate in-product banner.
|
||||
Its primary job is fast route orientation. The modal should explain what each
|
||||
top-level area is for in plain product language, so operators can understand
|
||||
the new navigation in one pass without needing historical layout context.
|
||||
That copy should stay direct and present-tense. Each guided step should say
|
||||
what the destination does, not depend on v5 comparisons, migration framing, or
|
||||
older information architecture to make sense.
|
||||
The Infrastructure tour step must describe the source model directly: platform
|
||||
API inventory, Pulse Agent telemetry, and discovered candidates are managed as
|
||||
infrastructure sources in one place.
|
||||
That guided welcome surface should stay compact. The canonical shape is a
|
||||
coachmark-sized card centered on the current destination with one short
|
||||
step-specific sentence, a small clickable step strip, and minimal footer
|
||||
controls. It must not grow back into a large sectioned explainer when one
|
||||
sentence would do the job.
|
||||
The guided stop map inside that welcome surface is interactive, not decorative:
|
||||
operators must be able to jump directly to any tour step from the stop list,
|
||||
and desktop layouts may widen the panel enough to keep step labels readable
|
||||
without overlapping or collapsing into clipped pills. That map should read as
|
||||
numbered wayfinding, not placeholder onboarding chrome: concise numeric badges
|
||||
plus section titles are preferred over repeated `Stop N` copy or other filler
|
||||
labels that add noise without helping orientation.
|
||||
That same welcome surface must stay inside Pulse's existing flat visual
|
||||
language. The shell, step map, telemetry note, and supporting actions should
|
||||
use bordered flat fills and normal app radii instead of gradient washes,
|
||||
glassmorphism, or other marketing-style promo chrome that drifts from the rest
|
||||
of the product.
|
||||
Secondary disclosures such as telemetry must stay subordinate to that
|
||||
orientation job: keep them as footer-level links into the canonical
|
||||
privacy/settings surfaces, and do not let them crowd out the migration
|
||||
wayfinding copy. The supporting docs CTA on that surface should likewise stay
|
||||
route-oriented: use a neutral `Navigation guide` label and plain present-tense
|
||||
copy that helps operators understand the current IA, rather than reviving
|
||||
`Migration guide` branding that pulls the tour back into v5 historical framing.
|
||||
That state owner now also owns public-demo suppression: the modal must stay
|
||||
closed until `sessionPresentationPolicyResolved()` is true and must fail closed
|
||||
when `presentationPolicyIsDemoMode()` resolves true, so the public demo does
|
||||
not front-load product migration onboarding ahead of the actual surface.
|
||||
Canonical customer disclosures inside those shared shells now route through
|
||||
`frontend-modern/src/utils/docsLinks.ts`, so settings and what's-new privacy
|
||||
links resolve to shipped `/docs/...` assets instead of hard-coded GitHub
|
||||
`main` URLs that can drift from the running build.
|
||||
Canonical customer disclosures inside shared shells route through
|
||||
`frontend-modern/src/utils/docsLinks.ts`, so settings privacy links resolve to
|
||||
shipped `/docs/...` assets instead of hard-coded GitHub `main` URLs that can
|
||||
drift from the running build.
|
||||
The shared summary strip primitives now follow that same owner split.
|
||||
`frontend-modern/src/components/shared/SummaryPanel.tsx` and
|
||||
`frontend-modern/src/components/shared/SummaryMetricCard.tsx` stay the render
|
||||
|
|
|
|||
|
|
@ -3553,7 +3553,6 @@
|
|||
"frontend-modern/src/components/shared/__tests__/TagBadges.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/UpgradeLink.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/WebInterfaceUrlField.test.tsx",
|
||||
"frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx",
|
||||
"frontend-modern/src/components/shared/ColumnPicker.test.tsx",
|
||||
"frontend-modern/src/components/shared/FilterToolbar.test.tsx",
|
||||
"frontend-modern/src/components/shared/PageControls.guardrails.test.ts",
|
||||
|
|
|
|||
|
|
@ -315,11 +315,10 @@ abuse controls, `docs/PRIVACY.md` and the shipped
|
|||
`frontend-modern/public/docs/PRIVACY.md` copy must say so explicitly rather
|
||||
than implying the server stores nothing at all.
|
||||
That same rule also applies to the short in-product summary on the shared
|
||||
General settings privacy surface and the whats-new disclosure copy. Those
|
||||
surfaces may stay concise, but they must not claim a stronger privacy posture
|
||||
than the governed docs; if telemetry rows are retained for a fixed window and
|
||||
IP addresses are not stored rather than “never seen,” the summary copy must
|
||||
say that plainly.
|
||||
General settings privacy surface. That surface may stay concise, but it must
|
||||
not claim a stronger privacy posture than the governed docs; if telemetry rows
|
||||
are retained for a fixed window and IP addresses are not stored rather than
|
||||
“never seen,” the summary copy must say that plainly.
|
||||
That same shared trust boundary now also owns the TLS floor used by pinned-
|
||||
fingerprint runtime clients. `pkg/tlsutil/fingerprint.go` may support
|
||||
certificate-fingerprint capture and verification for self-signed deployments,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { logger } from './utils/logger';
|
|||
import { UpdateBanner } from './components/UpdateBanner';
|
||||
import { DemoBanner } from './components/DemoBanner';
|
||||
import { GitHubStarBanner } from './components/GitHubStarBanner';
|
||||
import { WhatsNewModal } from './components/shared/WhatsNewModal';
|
||||
import { KeyboardShortcutsModal } from './components/shared/KeyboardShortcutsModal';
|
||||
import { CommandPaletteModal } from './components/shared/CommandPaletteModal';
|
||||
import { dialogStackHasBlockingDialog } from './components/shared/useDialogState';
|
||||
|
|
@ -379,7 +378,6 @@ function App() {
|
|||
<DemoBanner />
|
||||
<UpdateBanner />
|
||||
<GitHubStarBanner />
|
||||
<WhatsNewModal />
|
||||
<GlobalUpdateProgressWatcher />
|
||||
</Show>
|
||||
{/* Main layout container - flexbox to allow AI panel to push content */}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,8 @@ import Sun from 'lucide-solid/icons/sun';
|
|||
import Moon from 'lucide-solid/icons/moon';
|
||||
import Thermometer from 'lucide-solid/icons/thermometer';
|
||||
import Maximize2 from 'lucide-solid/icons/maximize-2';
|
||||
import Compass from 'lucide-solid/icons/compass';
|
||||
import { temperatureStore } from '@/utils/temperature';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
import { WHATS_NEW_REOPEN_EVENT } from '@/components/shared/whatsNewModalModel';
|
||||
import {
|
||||
PVE_POLLING_MAX_SECONDS,
|
||||
PVE_POLLING_MIN_SECONDS,
|
||||
|
|
@ -167,31 +165,6 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
|||
onChange={() => layoutStore.toggle()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reopen Navigation Guide */}
|
||||
<div class="flex items-center justify-between gap-4 p-4 sm:p-6">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="shrink-0 p-2.5 rounded-md border border-border bg-surface">
|
||||
<Compass class="w-5 h-5 text-slate-500" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="text-sm text-muted min-w-0">
|
||||
<p class="font-medium text-base-content truncate">Navigation guide</p>
|
||||
<p class="text-xs text-muted line-clamp-2">
|
||||
Replay the four-stop walkthrough of Infrastructure, Workloads, Storage, and
|
||||
Recovery.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent(WHATS_NEW_REOPEN_EVENT));
|
||||
}}
|
||||
class="shrink-0 rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
Reopen
|
||||
</button>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
|
||||
{/* Usage Data + Privacy Card */}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ import infrastructureSelectorSource from '@/components/shared/InfrastructureSele
|
|||
import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
|
||||
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
|
||||
import progressBarSource from '@/components/shared/ProgressBar.tsx?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';
|
||||
|
|
@ -102,7 +100,6 @@ 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 searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw';
|
||||
|
|
@ -1309,39 +1306,6 @@ 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('useDialogState');
|
||||
expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS');
|
||||
expect(whatsNewModalSource).toContain('Portal');
|
||||
expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');
|
||||
expect(whatsNewModalSource).not.toContain('createSignal');
|
||||
expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN');
|
||||
expect(whatsNewModalSource).not.toContain('Migration guide');
|
||||
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('createMemo');
|
||||
expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN');
|
||||
expect(whatsNewModalStateSource).toContain('sessionPresentationPolicyResolved');
|
||||
expect(whatsNewModalStateSource).toContain('presentationPolicyIsDemoMode');
|
||||
expect(whatsNewModalStateSource).toContain('handleClose');
|
||||
expect(whatsNewModalStateSource).toContain('handleNext');
|
||||
expect(whatsNewModalStateSource).toContain('spotlightStyle');
|
||||
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL');
|
||||
expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL');
|
||||
expect(whatsNewModalModelSource).toContain('Telemetry details');
|
||||
expect(whatsNewModalModelSource).toContain("title: 'Infrastructure'");
|
||||
});
|
||||
|
||||
it('keeps dialog stack visibility in the shared dialog runtime', () => {
|
||||
expect(dialogStateSource).toContain('export function dialogStackHasBlockingDialog');
|
||||
expect(dialogStateSource).toContain('createSignal');
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
import { For, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import ServerIcon from 'lucide-solid/icons/server';
|
||||
import BoxesIcon from 'lucide-solid/icons/boxes';
|
||||
import HardDriveIcon from 'lucide-solid/icons/hard-drive';
|
||||
import ShieldCheckIcon from 'lucide-solid/icons/shield-check';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import {
|
||||
WHATS_NEW_BACK_LABEL,
|
||||
WHATS_NEW_CLOSE_LABEL,
|
||||
WHATS_NEW_DOCS_LABEL,
|
||||
WHATS_NEW_DOCS_URL,
|
||||
WHATS_NEW_DO_NOT_SHOW_LABEL,
|
||||
WHATS_NEW_FEATURE_CARDS,
|
||||
WHATS_NEW_KICKER_LABEL,
|
||||
WHATS_NEW_NEXT_LABEL,
|
||||
WHATS_NEW_PRIMARY_ACTION_LABEL,
|
||||
WHATS_NEW_PROGRESS_PREFIX,
|
||||
WHATS_NEW_PRIVACY_URL,
|
||||
WHATS_NEW_TELEMETRY_LINK_LABEL,
|
||||
WHATS_NEW_TITLE,
|
||||
type WhatsNewFeatureCard,
|
||||
} from './whatsNewModalModel';
|
||||
import { useDialogState } from './useDialogState';
|
||||
import { useWhatsNewModalState } from './useWhatsNewModalState';
|
||||
|
||||
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 state = useWhatsNewModalState();
|
||||
const dialogState = useDialogState({
|
||||
get isOpen() {
|
||||
return state.isOpen();
|
||||
},
|
||||
onClose: state.handleClose,
|
||||
});
|
||||
const step = () => state.currentStep();
|
||||
const setPanelRef = (element: HTMLDivElement) => {
|
||||
state.setPanelRef(element);
|
||||
dialogState.setPanelRef(element);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={state.isOpen()}>
|
||||
<Portal mount={document.body}>
|
||||
<div class="fixed inset-0 z-[1000]">
|
||||
<div class="absolute inset-0" data-dialog-backdrop onClick={dialogState.handleBackdropClick} />
|
||||
<Show when={state.spotlightStyle()}>
|
||||
{(style) => (
|
||||
<div
|
||||
data-tour-spotlight=""
|
||||
data-tour-step={step().target}
|
||||
class="pointer-events-none absolute rounded-[1.25rem] border border-blue-300/80 bg-white/10 transition-all duration-200 dark:border-blue-300/60"
|
||||
style={style()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
ref={setPanelRef}
|
||||
data-tour-panel=""
|
||||
data-tour-step={step().target}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={WHATS_NEW_TITLE}
|
||||
tabindex="-1"
|
||||
class="fixed z-[1001] max-h-[min(90vh,32rem)] overflow-y-auto rounded-md border border-border bg-surface shadow-xl focus:outline-none"
|
||||
style={state.panelStyle()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-start justify-between border-b border-border bg-surface px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
<span class="inline-flex items-center rounded-full border border-border bg-surface-hover px-2 py-1">
|
||||
{WHATS_NEW_KICKER_LABEL}
|
||||
</span>
|
||||
<span>
|
||||
{WHATS_NEW_PROGRESS_PREFIX} {state.stepIndex() + 1} of {state.stepCount()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-start gap-3">
|
||||
<div class={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md border bg-surface ${step().accent}`}>
|
||||
<WhatsNewFeatureIcon card={step()} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-lg font-semibold text-base-content">{step().title}</div>
|
||||
<p class="mt-1 text-sm leading-6 text-muted">{step().description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={state.handleClose}
|
||||
class="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-surface-hover hover:text-muted"
|
||||
aria-label={WHATS_NEW_CLOSE_LABEL}
|
||||
type="button"
|
||||
>
|
||||
<XIcon class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={WHATS_NEW_FEATURE_CARDS}>
|
||||
{(card, index) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.handleSelectStep(index())}
|
||||
aria-current={index() === state.stepIndex() ? 'step' : undefined}
|
||||
class={`inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
index() === state.stepIndex()
|
||||
? 'border-blue-300 bg-blue-50 text-blue-900 dark:border-blue-700 dark:bg-blue-950 dark:text-blue-100'
|
||||
: 'border-border bg-surface-hover text-base-content hover:border-slate-300 hover:bg-surface dark:hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-flex h-5 w-5 items-center justify-center rounded-sm border text-[10px] font-semibold font-mono ${
|
||||
index() === state.stepIndex()
|
||||
? 'border-blue-200 bg-surface text-blue-700 dark:border-blue-800 dark:bg-slate-900 dark:text-blue-200'
|
||||
: 'border-border bg-surface text-muted'
|
||||
}`}
|
||||
>
|
||||
{String(index() + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span>{card.title}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-muted">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.dontShowAgain()}
|
||||
onChange={(event) => state.setDontShowAgain(event.currentTarget.checked)}
|
||||
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{WHATS_NEW_DO_NOT_SHOW_LABEL}
|
||||
</label>
|
||||
<a
|
||||
href={WHATS_NEW_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-content"
|
||||
>
|
||||
{WHATS_NEW_DOCS_LABEL}
|
||||
</a>
|
||||
<a
|
||||
href={WHATS_NEW_PRIVACY_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-base-content"
|
||||
>
|
||||
{WHATS_NEW_TELEMETRY_LINK_LABEL}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.handlePrevious}
|
||||
disabled={state.isFirstStep()}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{WHATS_NEW_BACK_LABEL}
|
||||
</button>
|
||||
<button
|
||||
onClick={state.handleNext}
|
||||
class="rounded-md bg-blue-600 px-3.5 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
type="button"
|
||||
>
|
||||
{state.isLastStep() ? WHATS_NEW_PRIMARY_ACTION_LABEL : WHATS_NEW_NEXT_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export default WhatsNewModal;
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } 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';
|
||||
|
||||
const presentationPolicyIsDemoModeMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const sessionPresentationPolicyResolvedMock = vi.hoisted(() => vi.fn(() => true));
|
||||
|
||||
vi.mock('@/stores/sessionPresentationPolicy', () => ({
|
||||
presentationPolicyIsDemoMode: presentationPolicyIsDemoModeMock,
|
||||
sessionPresentationPolicyResolved: sessionPresentationPolicyResolvedMock,
|
||||
}));
|
||||
|
||||
describe('WhatsNewModal', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
localStorage.clear();
|
||||
presentationPolicyIsDemoModeMock.mockReturnValue(false);
|
||||
sessionPresentationPolicyResolvedMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps whats new modal on shell, runtime, and model owners', () => {
|
||||
expect(whatsNewModalSource).toContain('useWhatsNewModalState');
|
||||
expect(whatsNewModalSource).toContain('useDialogState');
|
||||
expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS');
|
||||
expect(whatsNewModalSource).toContain('Portal');
|
||||
expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');
|
||||
expect(whatsNewModalSource).not.toContain('createSignal');
|
||||
expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN');
|
||||
expect(whatsNewModalSource).not.toContain('Migration guide');
|
||||
expect(whatsNewModalSource).not.toContain(
|
||||
'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md',
|
||||
);
|
||||
expect(whatsNewModalSource).not.toContain('bg-gradient');
|
||||
expect(whatsNewModalSource).not.toContain('backdrop-blur-sm');
|
||||
|
||||
expect(whatsNewModalStateSource).toContain('export function useWhatsNewModalState');
|
||||
expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal');
|
||||
expect(whatsNewModalStateSource).toContain('createSignal');
|
||||
expect(whatsNewModalStateSource).toContain('createMemo');
|
||||
expect(whatsNewModalStateSource).toContain('handleNext');
|
||||
expect(whatsNewModalStateSource).toContain('handlePrevious');
|
||||
expect(whatsNewModalStateSource).toContain('spotlightStyle');
|
||||
expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN');
|
||||
expect(whatsNewModalStateSource).toContain('sessionPresentationPolicyResolved');
|
||||
expect(whatsNewModalStateSource).toContain('presentationPolicyIsDemoMode');
|
||||
expect(whatsNewModalStateSource).toContain('handleClose');
|
||||
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL');
|
||||
expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL');
|
||||
expect(whatsNewModalModelSource).toContain('PRIVACY_DOC_URL');
|
||||
expect(whatsNewModalModelSource).toContain('Telemetry details');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_KICKER_LABEL');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_PROGRESS_PREFIX');
|
||||
expect(whatsNewModalModelSource).toContain("WHATS_NEW_PRIMARY_ACTION_LABEL = 'Done'");
|
||||
expect(whatsNewModalModelSource).not.toContain(
|
||||
'https://github.com/rcourtman/Pulse/blob/main/docs/README.md',
|
||||
);
|
||||
expect(whatsNewModalModelSource).not.toContain(
|
||||
'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md',
|
||||
);
|
||||
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 />);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Pulse navigation guide' });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Step 1 of 4')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Nav guide')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText(/Start here to add, inspect, and manage infrastructure sources/i),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText('Where Things Moved')).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('link', { name: 'Navigation guide' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('link', { name: 'Telemetry details' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays hidden for public demo sessions', async () => {
|
||||
presentationPolicyIsDemoModeMock.mockReturnValue(true);
|
||||
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on backdrop click and records the modal as seen by default', async () => {
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
const backdrop = await waitFor(() => {
|
||||
const element = document.querySelector('[data-dialog-backdrop]') as HTMLElement | null;
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN)).toBe('true');
|
||||
});
|
||||
|
||||
it('supports a session-only dismissal when "Don\'t show again" is unchecked', async () => {
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
const checkbox = await screen.findByRole('checkbox', { name: "Don't show again" });
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN)).toBe('false');
|
||||
|
||||
cleanup();
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('advances through the guided tour and finishes on the last step', async () => {
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Start here to add, inspect, and manage infrastructure sources/i),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(await screen.findByText(/Use this for VMs, containers, pods/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(await screen.findByText(/Use this for pools, datastores, disks/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(
|
||||
await screen.findByText(/Use this for backup coverage, snapshots, replication/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets the user jump to a tour stop directly from the stop map', async () => {
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Start here to add, inspect, and manage infrastructure sources/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workloads/i }));
|
||||
|
||||
expect(await screen.findByText(/Use this for VMs, containers, pods/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 2 of 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes the docs CTA through the navigation guide', async () => {
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
const docsLink = await screen.findByRole('link', { name: 'Navigation guide' });
|
||||
expect(docsLink).toHaveAttribute('href', '/docs/MIGRATION_UNIFIED_NAV.md');
|
||||
});
|
||||
|
||||
it('starts the navigation guide on recovery when opened from recovery', async () => {
|
||||
window.history.pushState({}, '', '/recovery?rollupId=res%3Asystem-container-1');
|
||||
|
||||
render(() => <WhatsNewModal />);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Pulse navigation guide' });
|
||||
expect(within(dialog).getByText('Step 4 of 4')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText(/Use this for backup coverage, snapshots, replication/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import {
|
||||
presentationPolicyIsDemoMode,
|
||||
sessionPresentationPolicyResolved,
|
||||
} from '@/stores/sessionPresentationPolicy';
|
||||
import { WHATS_NEW_FEATURE_CARDS, WHATS_NEW_REOPEN_EVENT } from './whatsNewModalModel';
|
||||
|
||||
type SpotlightRect = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const DESKTOP_TAB_SELECTOR_BY_TARGET = {
|
||||
infrastructure: '[role="tab"][title="All agents and nodes across platforms"]',
|
||||
workloads: '[role="tab"][title="VMs, containers, and Kubernetes workloads"]',
|
||||
storage: '[role="tab"][title="Storage pools, disks, and datastores"]',
|
||||
recovery: '[role="tab"][title="Backup, snapshot, and replication activity"]',
|
||||
} as const;
|
||||
|
||||
const MOBILE_TAB_SELECTOR_BY_TARGET = {
|
||||
infrastructure: 'button[data-tab-id="infrastructure"]',
|
||||
workloads: 'button[data-tab-id="workloads"]',
|
||||
storage: 'button[data-tab-id="storage"]',
|
||||
recovery: 'button[data-tab-id="recovery"]',
|
||||
} as const;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const getInitialStepIndexForPath = (): number => {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
const path = window.location.pathname.toLowerCase();
|
||||
const target = (() => {
|
||||
if (path.startsWith('/recovery')) return 'recovery';
|
||||
if (path.startsWith('/storage')) return 'storage';
|
||||
if (path.startsWith('/workloads')) return 'workloads';
|
||||
if (path.startsWith('/infrastructure')) return 'infrastructure';
|
||||
return 'infrastructure';
|
||||
})();
|
||||
const index = WHATS_NEW_FEATURE_CARDS.findIndex((card) => card.target === target);
|
||||
return index >= 0 ? index : 0;
|
||||
};
|
||||
|
||||
const isVisibleElement = (element: Element | null): element is HTMLElement => {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
};
|
||||
|
||||
const selectFirstVisible = (...selectors: string[]): HTMLElement | null => {
|
||||
for (const selector of selectors) {
|
||||
const visible = Array.from(document.querySelectorAll(selector)).find((candidate) =>
|
||||
isVisibleElement(candidate),
|
||||
);
|
||||
if (visible && visible instanceof HTMLElement) {
|
||||
return visible;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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 [stepIndex, setStepIndex] = createSignal(getInitialStepIndexForPath());
|
||||
const [panelRef, setPanelRef] = createSignal<HTMLDivElement | null>(null);
|
||||
const [spotlightRect, setSpotlightRect] = createSignal<SpotlightRect | null>(null);
|
||||
|
||||
const isOpen = () =>
|
||||
sessionPresentationPolicyResolved() &&
|
||||
!presentationPolicyIsDemoMode() &&
|
||||
!hasSeen() &&
|
||||
!dismissedForSession();
|
||||
|
||||
const currentStep = createMemo(() => {
|
||||
const index = clamp(stepIndex(), 0, WHATS_NEW_FEATURE_CARDS.length - 1);
|
||||
return WHATS_NEW_FEATURE_CARDS[index];
|
||||
});
|
||||
|
||||
const isFirstStep = createMemo(() => stepIndex() === 0);
|
||||
const isLastStep = createMemo(() => stepIndex() >= WHATS_NEW_FEATURE_CARDS.length - 1);
|
||||
|
||||
const resetTourState = () => {
|
||||
setStepIndex(getInitialStepIndexForPath());
|
||||
setSpotlightRect(null);
|
||||
};
|
||||
|
||||
const closeTour = () => {
|
||||
resetTourState();
|
||||
if (dontShowAgain()) {
|
||||
setHasSeen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setDismissedForSession(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
closeTour();
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep()) {
|
||||
closeTour();
|
||||
return;
|
||||
}
|
||||
setStepIndex((current) => clamp(current + 1, 0, WHATS_NEW_FEATURE_CARDS.length - 1));
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setStepIndex((current) => clamp(current - 1, 0, WHATS_NEW_FEATURE_CARDS.length - 1));
|
||||
};
|
||||
|
||||
const handleSelectStep = (index: number) => {
|
||||
setStepIndex(clamp(index, 0, WHATS_NEW_FEATURE_CARDS.length - 1));
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const handleReopen = () => {
|
||||
setHasSeen(false);
|
||||
setDismissedForSession(false);
|
||||
setStepIndex(getInitialStepIndexForPath());
|
||||
};
|
||||
window.addEventListener(WHATS_NEW_REOPEN_EVENT, handleReopen);
|
||||
onCleanup(() => window.removeEventListener(WHATS_NEW_REOPEN_EVENT, handleReopen));
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!isOpen()) return;
|
||||
|
||||
const updateSpotlight = () => {
|
||||
const step = currentStep();
|
||||
const target = selectFirstVisible(
|
||||
DESKTOP_TAB_SELECTOR_BY_TARGET[step.target],
|
||||
MOBILE_TAB_SELECTOR_BY_TARGET[step.target],
|
||||
);
|
||||
if (!target) {
|
||||
setSpotlightRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const padding = 10;
|
||||
setSpotlightRect({
|
||||
top: Math.max(12, rect.top - padding),
|
||||
left: Math.max(12, rect.left - padding),
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2,
|
||||
});
|
||||
};
|
||||
|
||||
updateSpotlight();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => {
|
||||
updateSpotlight();
|
||||
});
|
||||
const panel = panelRef();
|
||||
if (resizeObserver && panel) {
|
||||
resizeObserver.observe(panel);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateSpotlight);
|
||||
window.addEventListener('scroll', updateSpotlight, true);
|
||||
|
||||
onCleanup(() => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateSpotlight);
|
||||
window.removeEventListener('scroll', updateSpotlight, true);
|
||||
});
|
||||
});
|
||||
|
||||
const panelStyle = createMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
};
|
||||
}
|
||||
|
||||
const rect = spotlightRect();
|
||||
const panel = panelRef();
|
||||
const desktopWidth = window.innerWidth >= 1024 ? 376 : 344;
|
||||
const panelWidth = Math.min(desktopWidth, window.innerWidth - 32);
|
||||
const panelHeight = panel?.offsetHeight ?? 260;
|
||||
|
||||
if (!rect) {
|
||||
return {
|
||||
width: `${panelWidth}px`,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
};
|
||||
}
|
||||
|
||||
const spaceBelow = window.innerHeight - (rect.top + rect.height);
|
||||
const prefersAbove = spaceBelow < panelHeight + 24 && rect.top > panelHeight + 24;
|
||||
const unclampedTop = prefersAbove ? rect.top - panelHeight - 20 : rect.top + rect.height + 20;
|
||||
const maxTop = Math.max(16, window.innerHeight - panelHeight - 16);
|
||||
const top = clamp(unclampedTop, 16, maxTop);
|
||||
const unclampedLeft = rect.left + rect.width / 2 - panelWidth / 2;
|
||||
const maxLeft = Math.max(16, window.innerWidth - panelWidth - 16);
|
||||
const left = clamp(unclampedLeft, 16, maxLeft);
|
||||
|
||||
return {
|
||||
width: `${panelWidth}px`,
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const spotlightStyle = createMemo(() => {
|
||||
const rect = spotlightRect();
|
||||
if (!rect) return null;
|
||||
|
||||
return {
|
||||
top: `${rect.top}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
'box-shadow':
|
||||
'0 0 0 9999px rgba(15, 23, 42, 0.78), 0 0 0 2px rgba(255, 255, 255, 0.4), 0 0 40px rgba(96, 165, 250, 0.75)',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
dontShowAgain,
|
||||
handleClose,
|
||||
handleNext,
|
||||
handlePrevious,
|
||||
handleSelectStep,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isOpen,
|
||||
panelStyle,
|
||||
setDontShowAgain,
|
||||
setPanelRef,
|
||||
spotlightStyle,
|
||||
stepCount: () => WHATS_NEW_FEATURE_CARDS.length,
|
||||
stepIndex,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { MIGRATION_GUIDE_DOC_URL, PRIVACY_DOC_URL } from '@/utils/docsLinks';
|
||||
|
||||
export interface WhatsNewFeatureCard {
|
||||
accent: string;
|
||||
description: string;
|
||||
icon: 'infrastructure' | 'workloads' | 'storage' | 'recovery';
|
||||
target: 'infrastructure' | 'workloads' | 'storage' | 'recovery';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const WHATS_NEW_DOCS_URL = MIGRATION_GUIDE_DOC_URL;
|
||||
export const WHATS_NEW_PRIVACY_URL = PRIVACY_DOC_URL;
|
||||
|
||||
export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [
|
||||
{
|
||||
accent: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900',
|
||||
description:
|
||||
'Start here to add, inspect, and manage infrastructure sources: platform API inventory, Pulse Agent telemetry, and discovered candidates.',
|
||||
icon: 'infrastructure',
|
||||
target: 'infrastructure',
|
||||
title: 'Infrastructure',
|
||||
},
|
||||
{
|
||||
accent: 'border-rose-200 bg-rose-50 dark:border-rose-800 dark:bg-rose-900',
|
||||
description: 'Use this for VMs, containers, pods, and other running workloads.',
|
||||
icon: 'workloads',
|
||||
target: 'workloads',
|
||||
title: 'Workloads',
|
||||
},
|
||||
{
|
||||
accent: 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900',
|
||||
description: 'Use this for pools, datastores, disks, datasets, and capacity.',
|
||||
icon: 'storage',
|
||||
target: 'storage',
|
||||
title: 'Storage',
|
||||
},
|
||||
{
|
||||
accent: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900',
|
||||
description: 'Use this for backup coverage, snapshots, replication, and restore readiness.',
|
||||
icon: 'recovery',
|
||||
target: 'recovery',
|
||||
title: 'Recovery',
|
||||
},
|
||||
];
|
||||
|
||||
export const WHATS_NEW_REOPEN_EVENT = 'pulse:reopen-nav-guide';
|
||||
|
||||
export const WHATS_NEW_KICKER_LABEL = 'Nav guide';
|
||||
export const WHATS_NEW_TITLE = 'Pulse navigation guide';
|
||||
export const WHATS_NEW_PROGRESS_PREFIX = 'Step';
|
||||
export const WHATS_NEW_BACK_LABEL = 'Back';
|
||||
export const WHATS_NEW_CLOSE_LABEL = 'Close';
|
||||
export const WHATS_NEW_DOCS_LABEL = 'Navigation guide';
|
||||
export const WHATS_NEW_DO_NOT_SHOW_LABEL = "Don't show again";
|
||||
export const WHATS_NEW_NEXT_LABEL = 'Next';
|
||||
export const WHATS_NEW_PRIMARY_ACTION_LABEL = 'Done';
|
||||
export const WHATS_NEW_TELEMETRY_LINK_LABEL = 'Telemetry details';
|
||||
|
|
@ -36,8 +36,6 @@ 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';
|
||||
|
|
@ -77,7 +75,6 @@ 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 searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw';
|
||||
|
|
@ -2916,30 +2913,6 @@ describe('frontend resource type boundaries', () => {
|
|||
expect(monitoredSystemPresentationSource).toContain(
|
||||
'export function getMonitoredSystemDisclosureToggleLabel',
|
||||
);
|
||||
expect(whatsNewModalSource).toContain('useWhatsNewModalState');
|
||||
expect(whatsNewModalSource).toContain('useDialogState');
|
||||
expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS');
|
||||
expect(whatsNewModalSource).toContain('Portal');
|
||||
expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');
|
||||
expect(whatsNewModalSource).not.toContain('createSignal');
|
||||
expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN');
|
||||
expect(whatsNewModalSource).not.toContain('Migration guide');
|
||||
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('createMemo');
|
||||
expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN');
|
||||
expect(whatsNewModalStateSource).toContain('handleClose');
|
||||
expect(whatsNewModalStateSource).toContain('handleNext');
|
||||
expect(whatsNewModalStateSource).toContain('spotlightStyle');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_LINK_LABEL');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL');
|
||||
expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL');
|
||||
expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL');
|
||||
expect(tooltipSource).toContain('useTooltipState');
|
||||
expect(tooltipSource).toContain('createTooltipSystemState');
|
||||
expect(tooltipSource).not.toContain('createSignal');
|
||||
|
|
|
|||
|
|
@ -208,7 +208,6 @@ export const STORAGE_KEYS = {
|
|||
|
||||
// Feature discovery
|
||||
DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips',
|
||||
WHATS_NEW_NAV_V2_SHOWN: 'pulse_whats_new_v2_shown',
|
||||
DEBUG_MODE: 'pulse_debug_mode',
|
||||
|
||||
// GitHub star prompt
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect, devices } from '@playwright/test';
|
||||
import { dismissWhatsNewModal, ensureAuthenticated } from './helpers';
|
||||
import { ensureAuthenticated } from './helpers';
|
||||
|
||||
const getViewportWidth = async (page: import('@playwright/test').Page): Promise<number> => {
|
||||
const size = page.viewportSize();
|
||||
|
|
@ -13,7 +13,6 @@ test.describe('Mobile viewport flows', () => {
|
|||
});
|
||||
|
||||
test('bottom nav bar is visible on mobile', async ({ page }) => {
|
||||
await dismissWhatsNewModal(page);
|
||||
await page.goto('/infrastructure');
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
|
||||
|
|
@ -39,7 +38,6 @@ test.describe('Mobile viewport flows', () => {
|
|||
});
|
||||
|
||||
test('MobileNavBar has safe-area padding on nav', async ({ page }) => {
|
||||
await dismissWhatsNewModal(page);
|
||||
await page.goto('/infrastructure');
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
|
||||
|
|
@ -55,8 +53,6 @@ test.describe('Mobile viewport flows', () => {
|
|||
});
|
||||
|
||||
test('Infrastructure filter bar does not overflow horizontally', async ({ page }) => {
|
||||
// Prevent WhatsNew modal from blocking the page.
|
||||
await dismissWhatsNewModal(page);
|
||||
await page.goto('/infrastructure');
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
|
|
@ -71,7 +67,6 @@ test.describe('Mobile viewport flows', () => {
|
|||
});
|
||||
|
||||
test('Infrastructure table wrapper enables horizontal overflow when needed', async ({ page }) => {
|
||||
await dismissWhatsNewModal(page);
|
||||
await page.goto('/infrastructure');
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
|
|
@ -104,8 +99,6 @@ test.describe('Mobile viewport flows', () => {
|
|||
});
|
||||
|
||||
test('Tapping a resource row opens the detail drawer', async ({ page }) => {
|
||||
// Prevent WhatsNew modal from intercepting row clicks.
|
||||
await dismissWhatsNewModal(page);
|
||||
await page.goto('/infrastructure');
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ async function applyThemePreference(
|
|||
localStorage.setItem('pulseThemePreference', pref);
|
||||
localStorage.setItem('darkMode', String(pref === 'dark'));
|
||||
localStorage.removeItem('pulse_dark_mode');
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
if (forceLoggedOut) {
|
||||
localStorage.setItem('just_logged_out', 'true');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,27 +50,6 @@ async function expectPopupDoc(
|
|||
await popup.close();
|
||||
}
|
||||
|
||||
async function expectSpotlightAround(spotlight: Locator, target: Locator) {
|
||||
await expect(spotlight).toBeVisible();
|
||||
await expect(target).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const spotlightBox = await spotlight.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
if (!spotlightBox || !targetBox) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
spotlightBox.x <= targetBox.x + 1 &&
|
||||
spotlightBox.y <= targetBox.y + 1 &&
|
||||
spotlightBox.x + spotlightBox.width >= targetBox.x + targetBox.width - 1 &&
|
||||
spotlightBox.y + spotlightBox.height >= targetBox.y + targetBox.height - 1
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function readTelemetryPreview(page: Page) {
|
||||
const preview = page.locator('pre[aria-label="Telemetry payload preview"]');
|
||||
await expect(preview).toBeVisible();
|
||||
|
|
@ -132,66 +111,4 @@ test.describe('Telemetry disclosure', () => {
|
|||
})
|
||||
.not.toBe(initialPreview.install_id);
|
||||
});
|
||||
|
||||
test('whats-new tour opens shipped privacy and navigation guide pages', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only telemetry disclosure coverage');
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem('pulse_whats_new_v2_shown');
|
||||
});
|
||||
|
||||
await page.goto('/infrastructure', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const spotlight = page.locator('[data-tour-spotlight]');
|
||||
const assistantLauncher = page.getByRole('button', { name: 'Expand Pulse Assistant' });
|
||||
const infrastructureTab = page.locator(
|
||||
'[role="tab"][title="All agents and nodes across platforms"]',
|
||||
);
|
||||
await expect(dialog).toHaveAttribute('aria-label', 'Pulse navigation guide');
|
||||
await expect(dialog.getByText('Step 1 of 4')).toBeVisible();
|
||||
await expect(dialog.getByText('Nav guide')).toBeVisible();
|
||||
await expect(assistantLauncher).toBeHidden();
|
||||
await expect(spotlight).toHaveAttribute('data-tour-step', 'infrastructure');
|
||||
await expect(dialog).toHaveAttribute('data-tour-step', 'infrastructure');
|
||||
await expectSpotlightAround(spotlight, infrastructureTab);
|
||||
await expect(
|
||||
dialog.getByText(/Start here to add, inspect, and manage infrastructure sources/i),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'Telemetry details' })).toBeVisible();
|
||||
|
||||
const privacyLink = dialog.getByRole('link', { name: 'Telemetry details' });
|
||||
await expect(privacyLink).toHaveAttribute('href', '/docs/PRIVACY.md');
|
||||
await expectPopupDoc(
|
||||
page,
|
||||
privacyLink,
|
||||
'/docs/PRIVACY.md',
|
||||
'Pulse currently has two usage-data scopes',
|
||||
);
|
||||
|
||||
const docsLink = dialog.getByRole('link', { name: 'Navigation guide' });
|
||||
await expect(docsLink).toHaveAttribute('href', '/docs/MIGRATION_UNIFIED_NAV.md');
|
||||
await expectPopupDoc(
|
||||
page,
|
||||
docsLink,
|
||||
'/docs/MIGRATION_UNIFIED_NAV.md',
|
||||
'Migration Guide: Unified Navigation',
|
||||
);
|
||||
|
||||
for (let step = 0; step < 3; step += 1) {
|
||||
await dialog.getByRole('button', { name: 'Next' }).click();
|
||||
}
|
||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(assistantLauncher).toBeVisible();
|
||||
|
||||
await assistantLauncher.click();
|
||||
await expect(page.getByRole('heading', { name: 'Pulse Assistant' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Observed context, provider-backed reasoning, and governed actions.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTitle('Pulse Assistant sessions').click();
|
||||
await expect(page.getByText('No previous assistant sessions')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,15 +68,6 @@ async function ensureMockModeEnabled(page: import('@playwright/test').Page): Pro
|
|||
}
|
||||
}
|
||||
|
||||
async function dismissWhatsNewModal(page: import('@playwright/test').Page): Promise<void> {
|
||||
const modalTitle = page.getByText('Welcome to Pulse v6');
|
||||
if (!(await modalTitle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
await page.getByRole('button', { name: 'Skip tour' }).click();
|
||||
await expect(modalTitle).toHaveCount(0);
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
|
|
@ -161,7 +152,6 @@ test.describe.serial('Workloads memory tail', () => {
|
|||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByTestId('workloads-summary')).toBeVisible();
|
||||
await dismissWhatsNewModal(page);
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
|
|
|||
|
|
@ -98,9 +98,6 @@ test.describe.serial('Storage summary chart continuity', () => {
|
|||
|
||||
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
});
|
||||
await page.goto('/storage', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/storage/);
|
||||
await expect(page.getByTestId('storage-summary')).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -231,10 +231,6 @@ base.describe('Demo mode commercial boundary', () => {
|
|||
let billingStateRequests = 0;
|
||||
let checkoutStartRequests = 0;
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
});
|
||||
|
||||
await page.route('**/api/security/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
|
@ -424,9 +420,6 @@ base.describe('Managed demo runtime commercial boundary', () => {
|
|||
base(
|
||||
'hides commercial surfaces and APIs without browser route stubs',
|
||||
async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
});
|
||||
await ensureAuthenticated(page);
|
||||
|
||||
const hiddenCommercialRequests = trackBrowserRequests(
|
||||
|
|
|
|||
|
|
@ -173,10 +173,6 @@ async function configureMonitoredSystemBillingFixtures(
|
|||
const runtimeCapabilities =
|
||||
fixtures.runtimeCapabilities ?? MONITORED_SYSTEM_RUNTIME_CAPABILITIES;
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
|
||||
await page.route("**/api/security/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
|
|
|||
|
|
@ -351,10 +351,6 @@ async function configureBillingFixtures(
|
|||
) {
|
||||
const activationState = options.activationState ?? { current: false };
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
|
||||
await context.route("**/api/security/status", async (route) => {
|
||||
await fulfillJSON(route, {
|
||||
hasAuthentication: true,
|
||||
|
|
|
|||
|
|
@ -18,10 +18,6 @@ test.describe('Release candidate shell', () => {
|
|||
|
||||
await waitForPulseReady(page);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
});
|
||||
|
||||
await page.route('**/api/security/status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
|
|
|
|||
|
|
@ -139,9 +139,6 @@ test.describe.serial('Workloads column layout', () => {
|
|||
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop runtime proof');
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
});
|
||||
|
||||
await ensureMockModeEnabled(page);
|
||||
await page.goto('/workloads', { waitUntil: 'domcontentloaded' });
|
||||
|
|
|
|||
|
|
@ -165,7 +165,6 @@ test("renders the commercial funnel diagnostics card in the browser", async ({
|
|||
}),
|
||||
);
|
||||
sessionStorage.setItem("pulse_auth_user", "admin");
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
|
||||
await page.route("**/api/security/status", async (route) => {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,6 @@ test.describe.serial('Infrastructure column layout', () => {
|
|||
|
||||
await page.setViewportSize({ width: 1920, height: 1200 });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
localStorage.setItem('fullWidthMode', 'full-width');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,12 +39,6 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
test.describe("runtime-home onboarding contract", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes the agent install handoff onto the shared infrastructure workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -150,10 +150,6 @@ test.describe.serial('Storage growth column', () => {
|
|||
|
||||
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
});
|
||||
|
||||
await page.goto('/storage', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/storage/);
|
||||
await expect(page.getByTestId('storage-summary')).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -139,9 +139,6 @@ test.describe.serial("Workloads Proxmox refresh stability", () => {
|
|||
);
|
||||
|
||||
await ensureMockModeEnabled(page);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
|
||||
await page.goto("/workloads?type=vm&platform=proxmox-pve", {
|
||||
waitUntil: "domcontentloaded",
|
||||
|
|
|
|||
|
|
@ -134,10 +134,6 @@ test.describe('Offline Proxmox node visibility', () => {
|
|||
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop runtime proof');
|
||||
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
});
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ test.describe('Organization sharing approval UI', () => {
|
|||
};
|
||||
|
||||
await page.addInitScript((orgId) => {
|
||||
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
|
||||
sessionStorage.setItem('pulse_org_id', orgId);
|
||||
localStorage.setItem('pulse_org_id', orgId);
|
||||
document.cookie = `pulse_org_id=${encodeURIComponent(orgId)}; Path=/; SameSite=Lax`;
|
||||
|
|
|
|||
|
|
@ -88,10 +88,6 @@ async function recordUpgradeMetricEvents(
|
|||
}
|
||||
|
||||
async function prepareOnboardingPage(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
|
||||
await stubConnectionsList(page);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -359,9 +359,6 @@ export async function ensureFirstRunExperience(
|
|||
page: Page,
|
||||
options: CompleteSetupWizardOptions = {},
|
||||
) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
await waitForPulseReady(page);
|
||||
const completionTarget = options.completionTarget ?? "install";
|
||||
|
||||
|
|
@ -506,24 +503,7 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) {
|
|||
.toBe("authenticated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the WhatsNew modal that appears on first visit by marking it as seen
|
||||
* in localStorage. This prevents the "fixed inset-0 z-50" overlay from blocking
|
||||
* clicks (logout button, row clicks, etc.) in tests.
|
||||
*/
|
||||
export async function dismissWhatsNewModal(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureAuthenticated(page: Page) {
|
||||
// Pre-set the WhatsNew modal localStorage key via an init script that runs before
|
||||
// any page script on every navigation. This prevents the "fixed inset-0 z-50"
|
||||
// overlay from appearing and blocking clicks (logout, row taps, etc.) in tests.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("pulse_whats_new_v2_shown", "true");
|
||||
});
|
||||
await waitForPulseReady(page);
|
||||
await maybeCompleteSetupWizard(page);
|
||||
await login(page);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue