mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
chore: add in-app What's New scaffolding (#1767)
* feat: add whats-new entries data model * feat: add useWhatsNewPreference hook * feat: add whats-new breathing animation * feat: add WhatsNewTrigger sparkle icon * feat: add whatsNew slot to TopBar * feat: add WhatsNewModal * feat: wire whats-new sparkle icon and modal into EditorLayout * feat: add What's New opt-out toggle to Settings * fix: seed What's New watermark for zero-entry releases A release that ships with entries.json still empty must stamp a watermark on first run, or an existing install can never distinguish itself from a genuinely fresh install once a later release adds its first real entry, silently swallowing that entry's unseen signal. * fix: constrain WhatsNewModal height and clarify settings copy Bound the dialog to 85vh as a flex column so ModalBody's fill can constrain the entry list to scroll while the header and footer stay pinned, matching ConfirmModal's pattern. Also clarifies the "Show What's New" helper text in Settings. * fix: drop What's New screenshots that fail to load Screenshots are authored by hand alongside the entry, so a typo'd or not-yet-added filename is a realistic mistake. Previously that left the browser's broken-image placeholder and alt text inside the card; now the image is dropped and the title, blurb, and doc link still render. * style: replace em dash in watermark comment with a comma * fix: make "Never show again" actually hide What's New Turning the feature off previously only stopped the breathing animation and left the sparkle icon in the top bar, which is not what that label means anywhere else. Opting out now removes the trigger entirely and closes the modal, and Settings > About is the single way back. Also brings the trigger in line with its top bar siblings: it now uses the search trigger's hover treatment and gains a visible keyboard focus ring, and the modal bounds itself with dvh rather than vh so the footer cannot sit under a mobile URL bar. The modal is rendered in the desktop branch only, since the bespoke mobile screens drop the top bar that carries its trigger. * test: cover the empty-entries and loader paths of What's New The shipped state has an empty entries.json, so the branch where there is no newest entry is the one actually running, yet nothing exercised it. Adds a sibling hook test mocking that state to pin the empty-string watermark write, and a loader test for the newest-is-last contract the watermark depends on plus the malformed-entry filter. Drops the unreachable array check in the loader and the assertion that mirrored it: TypeScript types the JSON import, so a non-array file fails the build rather than reaching that branch. * refactor: fold the What's New storage writers into one helper writeEnabled and writeLastSeenId were identical apart from the key and the value encoding, duplicating the comment explaining why a failed write is non-fatal. The boolean encoding now sits at its single call site. Also hoists the reversed entry list to module scope, since the source array is a module constant, and factors the repeated preference mock in the trigger test behind a helper. * chore: keep the What's New icon hidden until an entry exists With entries.json empty there is nothing to announce, so a permanent sparkle in the top bar would only ever open a modal reading "Nothing new to show yet". The trigger now renders nothing in that state, leaving the modal's empty state as a runtime fallback for entries that fail validation rather than the shipping experience. This keeps the scaffolding invisible until the first entry is authored, which is the change that actually surfaces the feature to users.
This commit is contained in:
@@ -304,3 +304,24 @@ describe('TopBar smart and compact modes', () => {
|
||||
expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar whatsNew slot', () => {
|
||||
it('renders the whatsNew slot before the search slot', () => {
|
||||
renderTopBar({
|
||||
whatsNew: <button aria-label="whats-new-marker">sparkle</button>,
|
||||
search: <button aria-label="search-marker">search</button>,
|
||||
});
|
||||
const whatsNewEl = screen.getByRole('button', { name: 'whats-new-marker' });
|
||||
const searchEl = screen.getByRole('button', { name: 'search-marker' });
|
||||
// DOM order, not visual order: whatsNew's position relative to search in
|
||||
// the source confirms it is placed before, not just present.
|
||||
expect(
|
||||
whatsNewEl.compareDocumentPosition(searchEl) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing extra when whatsNew is omitted', () => {
|
||||
renderTopBar();
|
||||
expect(screen.queryByLabelText('whats-new-marker')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: [] }));
|
||||
vi.mock('@/hooks/useWhatsNewPreference', () => ({
|
||||
useWhatsNewPreference: () => ({ enabled: true, hasUnseen: false, setEnabled: vi.fn(), markSeen: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { WhatsNewModal } from '../WhatsNewModal';
|
||||
|
||||
describe('WhatsNewModal with no entries', () => {
|
||||
it('shows an empty state instead of an empty list', () => {
|
||||
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
|
||||
expect(screen.getByText('Nothing new to show yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// vi.mock factories are hoisted above all other module-scope code, so a plain
|
||||
// array literal referenced from the factory would hit a temporal-dead-zone
|
||||
// ReferenceError; vi.hoisted() hoists the declaration itself alongside it.
|
||||
const mockEntries = vi.hoisted(() => [
|
||||
{ id: 'entry-a', title: 'First feature', blurb: 'Does the first thing.', screenshot: 'first.png' },
|
||||
{ id: 'entry-b', title: 'Second feature', blurb: 'Does the second thing.', docUrl: 'https://docs.sencho.io/features/second', screenshot: 'second.png' },
|
||||
]);
|
||||
vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: mockEntries }));
|
||||
|
||||
const mockSetEnabled = vi.fn();
|
||||
const mockMarkSeen = vi.fn();
|
||||
vi.mock('@/hooks/useWhatsNewPreference', () => ({
|
||||
useWhatsNewPreference: () => ({ enabled: true, hasUnseen: true, setEnabled: mockSetEnabled, markSeen: mockMarkSeen }),
|
||||
}));
|
||||
|
||||
import { WhatsNewModal } from '../WhatsNewModal';
|
||||
|
||||
describe('WhatsNewModal', () => {
|
||||
beforeEach(() => {
|
||||
mockSetEnabled.mockClear();
|
||||
mockMarkSeen.mockClear();
|
||||
});
|
||||
|
||||
it('renders every entry, newest first, with title and blurb', () => {
|
||||
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
|
||||
const titles = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
|
||||
expect(titles).toEqual(['Second feature', 'First feature']);
|
||||
expect(screen.getByText('Does the second thing.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a doc link only for entries that have one, and a screenshot for each that does', () => {
|
||||
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
|
||||
expect(screen.getAllByRole('link', { name: /Learn more/ })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('img').map((i) => i.getAttribute('src'))).toEqual([
|
||||
'/whats-new/second.png',
|
||||
'/whats-new/first.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops only the screenshot that fails to load, leaving the rest of the entry and other images intact', () => {
|
||||
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
|
||||
fireEvent.error(screen.getByAltText('Second feature'));
|
||||
expect(screen.queryByAltText('Second feature')).not.toBeInTheDocument();
|
||||
// A sibling entry's screenshot must survive, which is what makes the
|
||||
// failure set per-entry rather than a single global flag.
|
||||
expect(screen.getByAltText('First feature')).toBeInTheDocument();
|
||||
// The failed entry keeps everything except its image.
|
||||
expect(screen.getByText('Does the second thing.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Learn more/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the newest entry seen when opened', () => {
|
||||
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
|
||||
expect(mockMarkSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not mark seen when closed', () => {
|
||||
render(<WhatsNewModal open={false} onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
|
||||
expect(mockMarkSeen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('"Never show again" disables the preference and closes the modal', async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Never show again' }));
|
||||
expect(mockSetEnabled).toHaveBeenCalledWith(false);
|
||||
// The nav trigger disappears with the preference, so the modal must not
|
||||
// be left open behind it.
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('"Got it" closes the modal without touching the preference', async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Got it' }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(mockSetEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('"View full changelog" calls the callback', async () => {
|
||||
const onViewChangelog = vi.fn();
|
||||
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={onViewChangelog} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'View full changelog' }));
|
||||
expect(onViewChangelog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
// Separate file so entries can be mocked empty at module scope, matching the
|
||||
// state that actually ships until the first entry is authored.
|
||||
vi.mock('@/whats-new/entries', () => ({ whatsNewEntries: [] }));
|
||||
vi.mock('@/hooks/useWhatsNewPreference', () => ({
|
||||
useWhatsNewPreference: () => ({ enabled: true, hasUnseen: false, setEnabled: vi.fn(), markSeen: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { WhatsNewTrigger } from '../WhatsNewTrigger';
|
||||
|
||||
describe('WhatsNewTrigger with no entries authored', () => {
|
||||
it('stays out of the top bar entirely, even with the preference enabled', () => {
|
||||
render(<WhatsNewTrigger onClick={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: "What's new" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const mockPreference = vi.fn();
|
||||
vi.mock('@/hooks/useWhatsNewPreference', () => ({
|
||||
useWhatsNewPreference: () => mockPreference(),
|
||||
}));
|
||||
|
||||
// The committed entries.json is empty, which the trigger treats as "nothing to
|
||||
// announce". Every case below except the empty-file one needs an entry to exist.
|
||||
vi.mock('@/whats-new/entries', () => ({
|
||||
whatsNewEntries: [{ id: 'entry-a', title: 'A feature', blurb: 'Does a thing.' }],
|
||||
}));
|
||||
|
||||
import { WhatsNewTrigger } from '../WhatsNewTrigger';
|
||||
|
||||
function givenPreference(enabled: boolean, hasUnseen: boolean) {
|
||||
mockPreference.mockReturnValue({ enabled, hasUnseen, setEnabled: vi.fn(), markSeen: vi.fn() });
|
||||
}
|
||||
|
||||
describe('WhatsNewTrigger', () => {
|
||||
it('breathes when enabled and there are unseen entries', () => {
|
||||
givenPreference(true, true);
|
||||
render(<WhatsNewTrigger onClick={vi.fn()} />);
|
||||
const icon = screen.getByRole('button', { name: "What's new" }).querySelector('svg');
|
||||
expect(icon).toHaveClass('animate-whats-new-breathe');
|
||||
});
|
||||
|
||||
it('renders nothing at all when the preference is disabled', () => {
|
||||
givenPreference(false, true);
|
||||
render(<WhatsNewTrigger onClick={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: "What's new" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not breathe when there is nothing unseen', () => {
|
||||
givenPreference(true, false);
|
||||
render(<WhatsNewTrigger onClick={vi.fn()} />);
|
||||
const icon = screen.getByRole('button', { name: "What's new" }).querySelector('svg');
|
||||
expect(icon).not.toHaveClass('animate-whats-new-breathe');
|
||||
});
|
||||
|
||||
it('opens the modal when clicked', async () => {
|
||||
givenPreference(true, false);
|
||||
const onClick = vi.fn();
|
||||
render(<WhatsNewTrigger onClick={onClick} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: "What's new" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exposes a visible keyboard focus state', () => {
|
||||
givenPreference(true, false);
|
||||
render(<WhatsNewTrigger onClick={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: "What's new" })).toHaveClass('focus-visible:ring-2');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user