feat(walkthrough): add Getting Started tour with first-run invite
Bespoke spotlight walkthrough (no new deps) that guides new users through the main features. A version-stamped invite bubble offers the tour to new users and re-offers it once to existing users after an upgrade; restartable from Settings. - Multi-hole SVG-mask overlay keeps a ringed control and any open modal lit - Steps: scan, scan history, inventory, nodes, edit, text/zone, grouping, style, imports, and a closing recap with a GitHub link - Scan/history/inventory/import steps inject demo data (no backend calls) and are filtered out in standalone, which ends on a full-mode recap - Persisted invite state via localStorage (utils/walkthrough.ts), live tour state via zustand (stores/walkthroughStore.ts) ha-relevant: maybe
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderWithProviders, screen, fireEvent } from '@/test/render'
|
||||
import { WalkthroughInvite } from '../WalkthroughInvite'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
import { readWalkthrough } from '@/utils/walkthrough'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||
})
|
||||
|
||||
describe('WalkthroughInvite', () => {
|
||||
it('offers the tour to a new user', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
expect(screen.getByText('New here? Take the tour')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is hidden once already seen', () => {
|
||||
localStorage.setItem('homelable.walkthrough', JSON.stringify({ seenVersion: 1, status: 'completed' }))
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Getting started" launches the tour', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
fireEvent.click(screen.getByText('Getting started'))
|
||||
expect(useWalkthroughStore.getState().isActive).toBe(true)
|
||||
// Invite hides while the tour is active.
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Don\'t show again" persists and hides', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
fireEvent.click(screen.getByText("Don't show again"))
|
||||
expect(readWalkthrough().status).toBe('skipped')
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Not now" hides without persisting', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
fireEvent.click(screen.getByLabelText('Not now'))
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
// Not persisted → would be offered again on next load.
|
||||
expect(readWalkthrough().seenVersion).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { renderWithProviders, screen, fireEvent, cleanup, act } from '@/test/render'
|
||||
import { WalkthroughOverlay } from '../WalkthroughOverlay'
|
||||
import { getSteps } from '../steps'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
|
||||
const TOTAL = getSteps(false).length
|
||||
|
||||
const startTour = () => act(() => { useWalkthroughStore.getState().start() })
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||
})
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('WalkthroughOverlay', () => {
|
||||
it('renders nothing while inactive', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the first step and progress when active', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
startTour()
|
||||
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||
expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('advances and rewinds between steps', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
startTour()
|
||||
fireEvent.click(screen.getByText('Next'))
|
||||
expect(screen.getByText('Scan your network')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('Previous step'))
|
||||
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skip closes the overlay', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
startTour()
|
||||
fireEvent.click(screen.getByLabelText('Skip walkthrough'))
|
||||
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getSteps, STEPS } from '../steps'
|
||||
|
||||
describe('getSteps', () => {
|
||||
it('returns every step in full mode', () => {
|
||||
expect(getSteps(false)).toHaveLength(STEPS.length)
|
||||
})
|
||||
|
||||
it('drops backend-only (mode:full) steps in standalone', () => {
|
||||
const standalone = getSteps(true)
|
||||
expect(standalone.every((s) => s.mode !== 'full')).toBe(true)
|
||||
expect(standalone.length).toBeLessThan(STEPS.length)
|
||||
const ids = standalone.map((s) => s.id)
|
||||
// Canvas-only steps survive; backend-only steps are filtered out.
|
||||
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end']))
|
||||
expect(ids).not.toContain('scan')
|
||||
expect(ids).not.toContain('scan-history')
|
||||
expect(ids).not.toContain('inventory')
|
||||
expect(ids).not.toContain('imports')
|
||||
})
|
||||
|
||||
it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => {
|
||||
const last = getSteps(true).at(-1)
|
||||
expect(last?.id).toBe('end')
|
||||
expect(last?.link?.href).toContain('github.com')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user