Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c712a69c4a | ||
|
|
d460e8ac33 | ||
|
|
2b1569202a | ||
|
|
5c44dd545c | ||
|
|
2ac62c4e9e |
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
/* Same DOM-harness pattern as MediaStylePanel.video.test.tsx -- mock
|
||||||
|
@craftjs/core's useEditor so setProp calls can be observed without
|
||||||
|
mounting a real <Editor> tree, and mock utils/assets so AssetPicker
|
||||||
|
doesn't hit the network. */
|
||||||
|
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||||
|
updater(lastProps);
|
||||||
|
});
|
||||||
|
let lastProps: any;
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../utils/assets', () => ({
|
||||||
|
uploadAsset: vi.fn(),
|
||||||
|
listAssets: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ImageStylePanel } from './ImageStylePanel';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function q<T extends Element = Element>(testId: string): T | null {
|
||||||
|
return container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qAll<T extends Element = Element>(testId: string): T[] {
|
||||||
|
return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the preset button with this exact label inside a given data-testid
|
||||||
|
* root (AspectRatioControl / PresetButtonGrid render plain buttons keyed by
|
||||||
|
* label, no per-button testid). */
|
||||||
|
function clickPresetByLabel(root: Element | null, label: string) {
|
||||||
|
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setPropSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageStylePanel crop-fills-by-default (fix-anim-image B)', () => {
|
||||||
|
test('applying a non-empty aspect ratio with objectFit unset also sets objectFit to cover', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('1 / 1');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying a ratio when objectFit is already "contain" leaves it as contain (no forced override)', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { objectFit: 'contain' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('16 / 9');
|
||||||
|
expect(lastProps.style.objectFit).toBe('contain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing the ratio (Original) does not force-clear objectFit', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Object Fit control (Cover/Contain/Fill/None) remains present and usable', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
// PresetButtonGrid buttons have no dedicated per-button testid; find by label text.
|
||||||
|
const containBtn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'Contain');
|
||||||
|
expect(containBtn).toBeTruthy();
|
||||||
|
act(() => { containBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
expect(lastProps.style.objectFit).toBe('contain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageStylePanel Height control gated on aspect-ratio (fix-anim-image C)', () => {
|
||||||
|
test('no aspect-ratio set: both Width and Height SizeControls render', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
expect(qAll('size-control').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aspect-ratio set: only the Width SizeControl renders (Height is hidden)', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
expect(qAll('size-control').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying an aspect ratio clears a stale height so it cannot linger and conflict', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { height: '50%' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '9:16');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('9 / 16');
|
||||||
|
expect(lastProps.style.height).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,19 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
|
|
||||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
|
// Applying an aspect-ratio crop should FILL the frame by default (object-fit:
|
||||||
|
// cover) rather than leave letterboxed empty bands, and width + ratio + cover
|
||||||
|
// fully determine the box -- so a stale `height` can't linger and conflict
|
||||||
|
// (kills the %-height no-op that made resize look like it wasn't working).
|
||||||
|
// Clearing the ratio (back to 'Original') leaves objectFit as the user left it.
|
||||||
|
const applyAspectRatio = (v: string) => {
|
||||||
|
setPropStyle('aspectRatio', v);
|
||||||
|
if (v) {
|
||||||
|
if (!style.objectFit) setPropStyle('objectFit', 'cover');
|
||||||
|
setPropStyle('height', '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Image source */}
|
{/* Image source */}
|
||||||
@@ -54,18 +67,24 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
value={(style.maxWidth as string) || ''}
|
value={(style.maxWidth as string) || ''}
|
||||||
onChange={(v) => setPropStyle('maxWidth', v)}
|
onChange={(v) => setPropStyle('maxWidth', v)}
|
||||||
/>
|
/>
|
||||||
|
{/* Height is only meaningful when there's no aspect-ratio crop -- once a
|
||||||
|
ratio is set, Width + ratio + cover fully determine the box, so a
|
||||||
|
separate Height control would only conflict/mislead (see
|
||||||
|
applyAspectRatio, which clears any stale height at that moment). */}
|
||||||
|
{!style.aspectRatio && (
|
||||||
<SizeControl
|
<SizeControl
|
||||||
label="Height"
|
label="Height"
|
||||||
value={(style.height as string) || ''}
|
value={(style.height as string) || ''}
|
||||||
onChange={(v) => setPropStyle('height', v)}
|
onChange={(v) => setPropStyle('height', v)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
|
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
|
||||||
<img> itself is a CSS framing crop (no server-side image processing
|
<img> itself is a CSS framing crop (no server-side image processing
|
||||||
needed). */}
|
needed). */}
|
||||||
<AspectRatioControl
|
<AspectRatioControl
|
||||||
value={(style.aspectRatio as string) || ''}
|
value={(style.aspectRatio as string) || ''}
|
||||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
onChange={applyAspectRatio}
|
||||||
/>
|
/>
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Object Fit</SectionLabel>
|
<SectionLabel>Object Fit</SectionLabel>
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
|
|
||||||
const style = nodeProps.style || {};
|
const style = nodeProps.style || {};
|
||||||
|
|
||||||
|
// Same crop-fills-by-default + no stale-height-conflict treatment as
|
||||||
|
// ImageStylePanel (see there for the full rationale): applying a ratio
|
||||||
|
// defaults objectFit to 'cover' when unset, and clears height so Width +
|
||||||
|
// ratio + cover is the single source of truth for the box.
|
||||||
|
const applyAspectRatio = (v: string) => {
|
||||||
|
setPropStyle('aspectRatio', v);
|
||||||
|
if (v) {
|
||||||
|
if (!style.objectFit) setPropStyle('objectFit', 'cover');
|
||||||
|
setPropStyle('height', '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Video source -- upload/browse/paste-URL (paste-URL still handles
|
{/* Video source -- upload/browse/paste-URL (paste-URL still handles
|
||||||
@@ -63,9 +75,14 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
value={(style.width as string) || ''}
|
value={(style.width as string) || ''}
|
||||||
onChange={(v) => setPropStyle('width', v)}
|
onChange={(v) => setPropStyle('width', v)}
|
||||||
/>
|
/>
|
||||||
|
{/* NOTE: unlike ImageStylePanel, there is no separate Height control
|
||||||
|
here to gate on aspect-ratio -- VideoBlock's <video>/iframe size
|
||||||
|
themselves from `width` + `aspectRatio` directly (see
|
||||||
|
VideoBlock.tsx), not from an outer-wrapper height, so adding one
|
||||||
|
would reintroduce the exact empty-space bug this fix targets. */}
|
||||||
<AspectRatioControl
|
<AspectRatioControl
|
||||||
value={(style.aspectRatio as string) || ''}
|
value={(style.aspectRatio as string) || ''}
|
||||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
onChange={applyAspectRatio}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -121,6 +121,58 @@ describe('MediaStylePanel Video size controls (Width + Aspect Ratio) are gated o
|
|||||||
expect(q('size-control')).toBeNull();
|
expect(q('size-control')).toBeNull();
|
||||||
expect(q('aspect-ratio-control')).toBeNull();
|
expect(q('aspect-ratio-control')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a video-shaped selection only renders one SizeControl (Width) -- no separate Height control', () => {
|
||||||
|
// See MediaStylePanel.tsx's note: VideoBlock sizes its <video>/iframe from
|
||||||
|
// width + aspectRatio directly, not from an outer-wrapper height, so a
|
||||||
|
// Height control would reintroduce the empty-space bug this fix targets.
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
expect(qAll('size-control').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* FIX (fix-anim-image contract, B+C): applying a crop aspect-ratio to a video
|
||||||
|
should fill by default (objectFit defaults to 'cover' when unset) and clear
|
||||||
|
any stale height, matching ImageStylePanel's treatment -- for prop-schema
|
||||||
|
consistency even though VideoBlock's file-type <video> already hardcodes
|
||||||
|
object-fit: cover today. */
|
||||||
|
describe('MediaStylePanel Video AspectRatioControl applies crop-fills-by-default treatment (fix-anim-image B+C)', () => {
|
||||||
|
function clickPresetByLabel(root: Element | null, label: string) {
|
||||||
|
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('applying a non-empty ratio with objectFit unset also sets objectFit to cover', () => {
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('1 / 1');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying a ratio clears a stale height', () => {
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { height: '50%' } };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('16 / 9');
|
||||||
|
expect(lastProps.style.height).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing the ratio (Original) does not force-clear objectFit', () => {
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function openCollapsibleByTitle(title: string) {
|
function openCollapsibleByTitle(title: string) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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';
|
||||||
|
import { PublishWarnings } from './PublishWarnings';
|
||||||
|
|
||||||
|
/* ---------- DOM test harness (no @testing-library/react in this repo, see
|
||||||
|
src/ui/AssetPicker.test.tsx for the same pattern: react-dom/client +
|
||||||
|
react-dom/test-utils `act`, both transitive deps of react-dom already). ---------- */
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(el: Element | null) {
|
||||||
|
if (!el) throw new Error('element not found');
|
||||||
|
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PublishWarnings', () => {
|
||||||
|
test('renders nothing when there are no warnings', () => {
|
||||||
|
render(<PublishWarnings warnings={[]} onDismiss={() => {}} />);
|
||||||
|
expect(container.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders each warning', () => {
|
||||||
|
render(
|
||||||
|
<PublishWarnings
|
||||||
|
warnings={['First problem.', 'Second problem.']}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain('First problem.');
|
||||||
|
expect(container.textContent).toContain('Second problem.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dismiss fires the callback', () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<PublishWarnings warnings={['A problem.']} onDismiss={onDismiss} />);
|
||||||
|
click(container.querySelector('[data-testid="publish-warnings-dismiss"]'));
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface PublishWarningsProps {
|
||||||
|
warnings: string[];
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-blocking banner shown after a successful publish. The site IS live --
|
||||||
|
* these are things the customer should fix and re-publish, not failures. */
|
||||||
|
export const PublishWarnings: React.FC<PublishWarningsProps> = ({ warnings, onDismiss }) => {
|
||||||
|
if (!warnings.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="publish-warnings" role="status" data-testid="publish-warnings">
|
||||||
|
<i className="fa fa-exclamation-triangle" aria-hidden="true" />
|
||||||
|
<ul>
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
data-testid="publish-warnings-dismiss"
|
||||||
|
>
|
||||||
|
<i className="fa fa-times" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { DeviceMode } from '../../types';
|
|||||||
import { TemplateModal } from './TemplateModal';
|
import { TemplateModal } from './TemplateModal';
|
||||||
import { HeadCodeModal } from './HeadCodeModal';
|
import { HeadCodeModal } from './HeadCodeModal';
|
||||||
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
||||||
|
import { PublishWarnings } from './PublishWarnings';
|
||||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
|
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
||||||
|
const [publishWarnings, setPublishWarnings] = useState<string[]>([]);
|
||||||
const [isDraft, setIsDraft] = useState(false);
|
const [isDraft, setIsDraft] = useState(false);
|
||||||
// Mobile-A2: lifted from private useState into MobileChromeContext so
|
// Mobile-A2: lifted from private useState into MobileChromeContext so
|
||||||
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
||||||
@@ -100,11 +102,16 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
|
|
||||||
const handlePublish = useCallback(async () => {
|
const handlePublish = useCallback(async () => {
|
||||||
setPublishStatus('publishing');
|
setPublishStatus('publishing');
|
||||||
|
setPublishWarnings([]);
|
||||||
try {
|
try {
|
||||||
const result = await publish();
|
const result = await publish();
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
setPublishStatus('published');
|
setPublishStatus('published');
|
||||||
setIsDraft(false);
|
setIsDraft(false);
|
||||||
|
// The site published; these are fixable problems, not failures. Held
|
||||||
|
// independently of publishStatus so the 3s "Published" flash doesn't
|
||||||
|
// take the warning down with it.
|
||||||
|
setPublishWarnings(Array.isArray(result.warnings) ? result.warnings : []);
|
||||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||||
} else {
|
} else {
|
||||||
@@ -220,6 +227,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<nav className="topbar topbar-mobile">
|
<nav className="topbar topbar-mobile">
|
||||||
|
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
{isWHP && (
|
{isWHP && (
|
||||||
<a
|
<a
|
||||||
@@ -307,6 +315,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="topbar">
|
<nav className="topbar">
|
||||||
|
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
{isWHP && (
|
{isWHP && (
|
||||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
|
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ body {
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
/* Positioned ancestor for .publish-warnings (position: absolute; top:
|
||||||
|
100%), which is rendered as this <nav>'s first child in both the
|
||||||
|
desktop and mobile branches -- without this, it would anchor to the
|
||||||
|
viewport instead of sitting directly under the topbar. */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-left,
|
.topbar-left,
|
||||||
@@ -1884,3 +1889,33 @@ body {
|
|||||||
height: 44px !important;
|
height: 44px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Publish warnings banner -- non-blocking; the site DID publish. Anchored
|
||||||
|
to .topbar's `position: relative` (see above) so it drops down directly
|
||||||
|
beneath the bar in both the desktop and mobile branches.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.publish-warnings {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #422006;
|
||||||
|
border-bottom: 1px solid #a16207;
|
||||||
|
color: #fde68a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.publish-warnings ul { margin: 0; padding-left: 16px; flex: 1; }
|
||||||
|
.publish-warnings li { margin: 2px 0; }
|
||||||
|
.publish-warnings button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fde68a;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -491,3 +491,55 @@ describe('Preview body-replacement keeps exactly one reveal script (animation fi
|
|||||||
expect(buildAnimationScript(exportBodyHtml(plainState).html)).toBe('');
|
expect(buildAnimationScript(exportBodyHtml(plainState).html)).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX: bounce entrance-animation disappears after finishing + reads like a
|
||||||
|
* fade (see .superpowers/sdd/fix-anim-image-contract.md, section A). Root
|
||||||
|
* cause: the old `@keyframes bounce` set opacity at 0% and 60% but NOT at
|
||||||
|
* 100% -- with `animation-fill-mode: both`, on finish the element reverted
|
||||||
|
* to the base `[data-animation]{opacity:0}` rule and vanished. The fix is a
|
||||||
|
* springier keyframe that ends at `opacity:1`.
|
||||||
|
*/
|
||||||
|
describe('bounce keyframe ends at opacity:1 (fix-anim-image A)', () => {
|
||||||
|
const animatedState = (animation: string) => JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { tag: 'div', style: {}, animation },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const NEW_BOUNCE_MINIFIED = '@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}';
|
||||||
|
const OLD_BOUNCE_TAIL = '100%{transform:translateY(0)}}';
|
||||||
|
|
||||||
|
test('minified export contains the new springier bounce substring, byte-identical to the shared contract', () => {
|
||||||
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
|
||||||
|
expect(html).toContain(NEW_BOUNCE_MINIFIED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minified export does NOT contain the old bounce tail (100% with no opacity)', () => {
|
||||||
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
|
||||||
|
expect(html).not.toContain(OLD_BOUNCE_TAIL);
|
||||||
|
// Every keyframe's 100% frame in this doc must carry opacity:1 now.
|
||||||
|
expect(html).toContain('100%{opacity:1;transform:translateY(0)}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pretty (non-minified) export ends the bounce keyframe at 100% { opacity: 1; transform: translateY(0); }', () => {
|
||||||
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page', minifyCss: false });
|
||||||
|
const NEW_BOUNCE_PRETTY = '@keyframes bounce { 0% { opacity: 0; transform: translateY(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; transform: translateY(0); } }';
|
||||||
|
expect(html).toContain(NEW_BOUNCE_PRETTY);
|
||||||
|
expect(html).not.toContain('100% { transform: translateY(0); } }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other keyframes (fadeIn/slideUp/zoomIn) are unchanged', () => {
|
||||||
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
|
||||||
|
expect(html).toContain('@keyframes fadeIn{from{opacity:0}to{opacity:1}}');
|
||||||
|
expect(html).toContain('@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}');
|
||||||
|
expect(html).toContain('@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ const ANIMATION_CSS = `
|
|||||||
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
|
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
|
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
|
||||||
@keyframes bounce { 0% { opacity: 0; transform: translateY(30px); } 60% { opacity: 1; transform: translateY(-5px); } 100% { transform: translateY(0); } }
|
@keyframes bounce { 0% { opacity: 0; transform: translateY(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
[data-animation] { opacity: 0; }
|
[data-animation] { opacity: 0; }
|
||||||
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
|
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
|
||||||
@@ -349,7 +349,7 @@ const ANIMATION_CSS = `
|
|||||||
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
|
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
|
||||||
[data-animation="bounce"].animated { animation-name: bounce; }`;
|
[data-animation="bounce"].animated { animation-name: bounce; }`;
|
||||||
|
|
||||||
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(30px)}60%{opacity:1;transform:translateY(-5px)}100%{transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
|
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
|
||||||
|
|
||||||
const ANIMATION_SCRIPT = `<script>
|
const ANIMATION_SCRIPT = `<script>
|
||||||
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
||||||
|
|||||||
Reference in New Issue
Block a user