Surface publish warnings in the editor instead of discarding them #26
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the gap flagged by the whole-branch review: PublishWarnings.test.tsx
|
||||||
|
* covers the presentational banner in isolation, but nothing asserted that
|
||||||
|
* `result.warnings` from `publish()` actually reaches it through TopBar. That
|
||||||
|
* 3-line seam (handlePublish -> setPublishWarnings -> <PublishWarnings>) is
|
||||||
|
* exactly what regressed before this feature existed: the backend has always
|
||||||
|
* returned `warnings`, and TopBar discarded them by only checking
|
||||||
|
* `result.success` -- so the contact-form-relay warning was dead code for its
|
||||||
|
* entire life. This suite drives the real `<TopBar>` through a real button
|
||||||
|
* click and asserts the warnings show up, survive the 3s "Published" flash,
|
||||||
|
* don't taint the success/error status, and get cleared by a fresh publish.
|
||||||
|
*
|
||||||
|
* Mocks (same DOM-harness + `vi.mock('@craftjs/core', ...)` pattern as
|
||||||
|
* RenderNode.test.tsx / useWhpApi.load.test.tsx -- no @testing-library/react
|
||||||
|
* in this repo):
|
||||||
|
* - `@craftjs/core`'s `useEditor`: TopBar only needs inert undo/redo/query
|
||||||
|
* stubs, not a real Craft.js tree.
|
||||||
|
* - `useWhpApi`: this IS the seam under test -- `publish` is a controllable
|
||||||
|
* mock so each test can choose exactly what the "backend" returns.
|
||||||
|
* - TemplateModal / HeadCodeModal / SitesmithButton: sibling chrome
|
||||||
|
* unrelated to the warnings seam (portals, CodeMirror lazy-load,
|
||||||
|
* useSitesmith's own fetch calls) -- stubbed out so this suite stays
|
||||||
|
* focused, same as MobilePanelBar.test.tsx stubbing its sibling panels.
|
||||||
|
* `usePages`/`useSiteDesign`/`useMobileChrome` are left un-mocked and
|
||||||
|
* un-provided -- their default context values (defined in each context
|
||||||
|
* module) are harmless no-op stubs, and TopBar's desktop render path never
|
||||||
|
* needs more than that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
|
||||||
|
const query = {
|
||||||
|
serialize: () => '{}',
|
||||||
|
history: { canUndo: () => false, canRedo: () => false },
|
||||||
|
};
|
||||||
|
const actions = { history: { undo: vi.fn(), redo: vi.fn() } };
|
||||||
|
const collected = collector ? collector({}, query) : {};
|
||||||
|
return { actions, query, ...collected };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const publishMock = vi.fn();
|
||||||
|
vi.mock('../../hooks/useWhpApi', () => ({
|
||||||
|
useWhpApi: () => ({
|
||||||
|
save: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
publish: publishMock,
|
||||||
|
load: vi.fn().mockResolvedValue(null),
|
||||||
|
uploadAsset: vi.fn(),
|
||||||
|
isWHP: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./TemplateModal', () => ({ TemplateModal: () => null }));
|
||||||
|
vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null }));
|
||||||
|
vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
|
||||||
|
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||||
|
import { WhpConfig } from '../../types';
|
||||||
|
|
||||||
|
const whpConfig: WhpConfig = {
|
||||||
|
user: 'testuser',
|
||||||
|
apiUrl: '/panel/api/site-builder',
|
||||||
|
csrfToken: 'tok',
|
||||||
|
siteId: 1,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
siteName: 'Example Site',
|
||||||
|
backUrl: '/panel/sites',
|
||||||
|
isRoot: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<EditorConfigProvider config={whpConfig}>
|
||||||
|
<TopBar device="desktop" onDeviceChange={() => {}} showGuides={false} onToggleGuides={() => {}} />
|
||||||
|
</EditorConfigProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishButton(): HTMLButtonElement {
|
||||||
|
const btn = container.querySelector<HTMLButtonElement>('.topbar-btn.publish');
|
||||||
|
if (!btn) throw new Error('Publish button not found');
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** handlePublish does exactly one `await publish()` before touching state;
|
||||||
|
* two microtask flushes inside the same act() batch is enough to carry that
|
||||||
|
* through to the resulting re-render. */
|
||||||
|
async function clickPublish() {
|
||||||
|
await act(async () => {
|
||||||
|
publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
publishMock.mockReset();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TopBar publish-warnings wiring', () => {
|
||||||
|
test('warnings from publish() flow through into the rendered banner', async () => {
|
||||||
|
publishMock.mockResolvedValue({ success: true, warnings: ['Warning one.', 'Warning two.'] });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Warning one.');
|
||||||
|
expect(container.textContent).toContain('Warning two.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a clean publish (no warnings key) renders no banner at all', async () => {
|
||||||
|
publishMock.mockResolvedValue({ success: true });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a warning is non-blocking -- publish still reports success, not a failure', async () => {
|
||||||
|
publishMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
warnings: ['Submissions will not be delivered until an administrator enables it.'],
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
|
||||||
|
expect(container.querySelector('.save-indicator.error')).toBeNull();
|
||||||
|
expect(container.textContent).toContain('Submissions will not be delivered until an administrator enables it.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warnings survive the 3-second "Published" flash timer', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
publishMock.mockResolvedValue({ success: true, warnings: ['Sticks around after the flash.'] });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity: both the flash and the warning are up before the timer fires.
|
||||||
|
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain('Sticks around after the flash.');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The 3s timer resets publishStatus -> the "Published" flash is gone...
|
||||||
|
expect(container.querySelector('.publish-badge.published')).toBeNull();
|
||||||
|
// ...but publishWarnings lives in its own state and must NOT have been
|
||||||
|
// cleared by that same timer.
|
||||||
|
expect(container.textContent).toContain('Sticks around after the flash.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new publish attempt clears warnings left over from the previous one', async () => {
|
||||||
|
publishMock.mockResolvedValueOnce({ success: true, warnings: ['Old warning.'] });
|
||||||
|
render();
|
||||||
|
await clickPublish();
|
||||||
|
expect(container.textContent).toContain('Old warning.');
|
||||||
|
|
||||||
|
publishMock.mockResolvedValueOnce({ success: true });
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain('Old warning.');
|
||||||
|
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user