feat: form/visual mode toggle for CustomModifierEditor
- Add buttons in editor header to switch between 'form' and 'visual' modes - Persist user's preferred mode to localStorage - Wrap main layout area in conditional render - In 'visual' mode, center/right column replaced with VisualBuilderPane - Adds suite of UI tests with vitest + node testing with mocked localStorage
This commit is contained in:
parent
d9928fbb07
commit
8017a0d590
3 changed files with 934 additions and 144 deletions
71
packages/chess/src/ui/CustomModifierEditor.test.tsx
Normal file
71
packages/chess/src/ui/CustomModifierEditor.test.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { CustomModifierEditor } from './CustomModifierEditor.js';
|
||||
import React from 'react';
|
||||
|
||||
// Mock localStorage since we're in a Node environment for SSR tests
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
})
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(global, 'localStorage', {
|
||||
value: localStorageMock
|
||||
});
|
||||
|
||||
describe('CustomModifierEditor', () => {
|
||||
const LOCAL_STORAGE_KEY = 'houserules:custom-modifier-editor-mode:v1';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderEditor() {
|
||||
return renderToStaticMarkup(
|
||||
<CustomModifierEditor isOpen={true} onClose={() => {}} />
|
||||
);
|
||||
}
|
||||
|
||||
it("default mode is 'form' when localStorage empty", () => {
|
||||
const html = renderEditor();
|
||||
|
||||
// Toggle buttons should reflect form is active
|
||||
expect(html).toContain('data-testid="custom-modifier-editor-mode-form" aria-pressed="true"');
|
||||
expect(html).toContain('data-testid="custom-modifier-editor-mode-visual" aria-pressed="false"');
|
||||
|
||||
// Should render the tree view / inspector (form mode components)
|
||||
expect(html).toContain('Effect Sequence');
|
||||
expect(html).toContain('Parameter Inspector');
|
||||
});
|
||||
|
||||
it("reading mode='visual' from localStorage renders VisualBuilderPane", () => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, 'visual');
|
||||
const html = renderEditor();
|
||||
|
||||
// Toggle buttons should reflect visual is active
|
||||
expect(html).toContain('data-testid="custom-modifier-editor-mode-form" aria-pressed="false"');
|
||||
expect(html).toContain('data-testid="custom-modifier-editor-mode-visual" aria-pressed="true"');
|
||||
|
||||
// Should render VisualBuilderPane content, not form components
|
||||
expect(html).not.toContain('Effect Sequence');
|
||||
expect(html).not.toContain('Parameter Inspector');
|
||||
// VisualBuilderPane has 'Primitives' text and BlockList container
|
||||
expect(html).toContain('Primitives');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
CUSTOM_MODIFIER_RECIPES,
|
||||
type CustomModifierRecipe,
|
||||
} from '../modifiers/custom/recipes.js';
|
||||
import { VisualBuilderPane } from './visual-builder/VisualBuilderPane.js';
|
||||
import { ParamField } from './ParamField.js';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -126,6 +127,24 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
|
|||
// Templates picker state (built-in recipe gallery)
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
const [mode, setMode] = useState<'form' | 'visual'>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('houserules:custom-modifier-editor-mode:v1');
|
||||
return stored === 'visual' ? 'visual' : 'form';
|
||||
} catch {
|
||||
return 'form';
|
||||
}
|
||||
});
|
||||
|
||||
const setModeAndPersist = (m: 'form' | 'visual') => {
|
||||
setMode(m);
|
||||
try {
|
||||
localStorage.setItem('houserules:custom-modifier-editor-mode:v1', m);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Open library
|
||||
const openLibrary = () => {
|
||||
setLibraryItems(loadCustomModifierLibrary());
|
||||
|
|
@ -220,6 +239,32 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div role="group" aria-label="Editor mode" data-testid="custom-modifier-editor-mode-toggle" className="flex bg-neutral-100 p-0.5 rounded-lg border border-neutral-200 mr-2">
|
||||
<button
|
||||
data-testid="custom-modifier-editor-mode-form"
|
||||
aria-pressed={mode === 'form'}
|
||||
onClick={() => setModeAndPersist('form')}
|
||||
className={`px-3 py-1 text-sm font-semibold rounded-md transition-colors ${
|
||||
mode === 'form'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
Form
|
||||
</button>
|
||||
<button
|
||||
data-testid="custom-modifier-editor-mode-visual"
|
||||
aria-pressed={mode === 'visual'}
|
||||
onClick={() => setModeAndPersist('visual')}
|
||||
className={`px-3 py-1 text-sm font-semibold rounded-md transition-colors ${
|
||||
mode === 'visual'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
data-testid="custom-templates"
|
||||
onClick={() => setShowTemplates(true)}
|
||||
|
|
@ -268,154 +313,168 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
|
|||
{/* Main Body (3 Columns) */}
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
|
||||
{/* Left: Palette */}
|
||||
<aside className="w-64 border-r border-neutral-200 overflow-y-auto bg-neutral-50/50 flex flex-col p-4 gap-6 shrink-0">
|
||||
{Object.entries(CATEGORIES).map(([category, kinds]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-3">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{kinds.map((kind) => {
|
||||
const primitive = PRIMITIVE_REGISTRY.get(kind);
|
||||
if (!primitive) return null;
|
||||
// Build a richer hover tooltip: the long description
|
||||
// if present (full behaviour explanation), otherwise
|
||||
// the short palette blurb. Adds the first example
|
||||
// headline so users see a concrete use-case on hover.
|
||||
const tooltipParts = [
|
||||
primitive.longDescription ?? primitive.description,
|
||||
];
|
||||
if (primitive.examples && primitive.examples.length > 0) {
|
||||
tooltipParts.push(
|
||||
`\n\nExample — ${primitive.examples[0]!.title}: ${primitive.examples[0]!.effect}`,
|
||||
{/* Left: Palette - Only show in form mode (VisualBuilderPane has its own) */}
|
||||
{mode === 'form' && (
|
||||
<aside className="w-64 border-r border-neutral-200 overflow-y-auto bg-neutral-50/50 flex flex-col p-4 gap-6 shrink-0">
|
||||
{Object.entries(CATEGORIES).map(([category, kinds]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-3">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{kinds.map((kind) => {
|
||||
const primitive = PRIMITIVE_REGISTRY.get(kind);
|
||||
if (!primitive) return null;
|
||||
// Build a richer hover tooltip: the long description
|
||||
// if present (full behaviour explanation), otherwise
|
||||
// the short palette blurb. Adds the first example
|
||||
// headline so users see a concrete use-case on hover.
|
||||
const tooltipParts = [
|
||||
primitive.longDescription ?? primitive.description,
|
||||
];
|
||||
if (primitive.examples && primitive.examples.length > 0) {
|
||||
tooltipParts.push(
|
||||
`\n\nExample — ${primitive.examples[0]!.title}: ${primitive.examples[0]!.effect}`,
|
||||
);
|
||||
}
|
||||
const tooltip = tooltipParts.join('');
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
data-testid={`custom-primitive-palette-${kind}`}
|
||||
title={tooltip}
|
||||
className="text-left px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm hover:border-blue-300 hover:shadow transition-all group"
|
||||
onClick={() => {
|
||||
const newNode: EffectPrimitiveNode = {
|
||||
kind,
|
||||
params: generateDefaultParams(primitive.paramsSchema),
|
||||
};
|
||||
updateDescriptor((p) => ({
|
||||
...p,
|
||||
primitives: [...p.primitives, newNode],
|
||||
}));
|
||||
setSelectedIndex(descriptor.primitives.length); // select the newly added node
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-semibold text-neutral-800">
|
||||
{primitive.label}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 truncate mt-0.5">
|
||||
{primitive.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const tooltip = tooltipParts.join('');
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
data-testid={`custom-primitive-palette-${kind}`}
|
||||
title={tooltip}
|
||||
className="text-left px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm hover:border-blue-300 hover:shadow transition-all group"
|
||||
onClick={() => {
|
||||
const newNode: EffectPrimitiveNode = {
|
||||
kind,
|
||||
params: generateDefaultParams(primitive.paramsSchema),
|
||||
};
|
||||
updateDescriptor((p) => ({
|
||||
...p,
|
||||
primitives: [...p.primitives, newNode],
|
||||
}));
|
||||
setSelectedIndex(descriptor.primitives.length); // select the newly added node
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-semibold text-neutral-800">
|
||||
{primitive.label}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 truncate mt-0.5">
|
||||
{primitive.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Center: Tree View */}
|
||||
<main className="flex-[2] border-r border-neutral-200 overflow-y-auto bg-white p-6 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
|
||||
Effect Sequence ({descriptor.primitives.length})
|
||||
</h3>
|
||||
{descriptor.primitives.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-neutral-400 italic">
|
||||
Add primitives from the palette to build the modifier.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{descriptor.primitives.map((node, i) => {
|
||||
const isSelected = selectedIndex === i;
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-testid={`custom-primitive-node-${i}`}
|
||||
onClick={() => setSelectedIndex(i)}
|
||||
className={`
|
||||
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors
|
||||
${isSelected
|
||||
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20'
|
||||
: 'border-neutral-200 hover:border-neutral-300 bg-white hover:bg-neutral-50'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className={`
|
||||
flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0
|
||||
${isSelected ? 'bg-blue-200 text-blue-800' : 'bg-neutral-100 text-neutral-500'}
|
||||
`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-semibold ${isSelected ? 'text-blue-900' : 'text-neutral-800'}`}>
|
||||
{primitive?.label ?? node.kind}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 truncate">
|
||||
{JSON.stringify(node.params).substring(0, 60)}
|
||||
{JSON.stringify(node.params).length > 60 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateDescriptor((p) => ({
|
||||
...p,
|
||||
primitives: p.primitives.filter((_, idx) => idx !== i),
|
||||
}));
|
||||
if (selectedIndex === i) setSelectedIndex(null);
|
||||
else if (selectedIndex && selectedIndex > i) setSelectedIndex(selectedIndex - 1);
|
||||
}}
|
||||
title="Remove primitive"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right: Inspector */}
|
||||
<aside
|
||||
data-testid="custom-primitive-inspector"
|
||||
className="flex-[1.5] bg-neutral-50/30 overflow-y-auto p-6"
|
||||
>
|
||||
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
|
||||
Parameter Inspector
|
||||
</h3>
|
||||
{selectedIndex === null || !descriptor.primitives[selectedIndex] ? (
|
||||
<div className="flex items-center justify-center h-48 text-sm text-neutral-400 italic">
|
||||
Select a primitive to edit its parameters.
|
||||
</div>
|
||||
) : (
|
||||
<ParamField
|
||||
node={descriptor.primitives[selectedIndex]}
|
||||
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
|
||||
allPrimitives={descriptor.primitives}
|
||||
onChange={(newParams) => {
|
||||
updateDescriptor((p) => {
|
||||
const next = [...p.primitives];
|
||||
next[selectedIndex] = { kind: next[selectedIndex]!.kind, params: newParams };
|
||||
return { ...p, primitives: next };
|
||||
});
|
||||
}}
|
||||
{mode === 'visual' ? (
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<VisualBuilderPane
|
||||
descriptor={descriptor}
|
||||
onChange={setDescriptor}
|
||||
validationResult={validationResult}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</main>
|
||||
) : (
|
||||
<>
|
||||
{/* Center: Tree View */}
|
||||
<main className="flex-[2] border-r border-neutral-200 overflow-y-auto bg-white p-6 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
|
||||
Effect Sequence ({descriptor.primitives.length})
|
||||
</h3>
|
||||
{descriptor.primitives.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-neutral-400 italic">
|
||||
Add primitives from the palette to build the modifier.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{descriptor.primitives.map((node, i) => {
|
||||
const isSelected = selectedIndex === i;
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-testid={`custom-primitive-node-${i}`}
|
||||
onClick={() => setSelectedIndex(i)}
|
||||
className={`
|
||||
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors
|
||||
${isSelected
|
||||
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20'
|
||||
: 'border-neutral-200 hover:border-neutral-300 bg-white hover:bg-neutral-50'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className={`
|
||||
flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0
|
||||
${isSelected ? 'bg-blue-200 text-blue-800' : 'bg-neutral-100 text-neutral-500'}
|
||||
`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-semibold ${isSelected ? 'text-blue-900' : 'text-neutral-800'}`}>
|
||||
{primitive?.label ?? node.kind}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 truncate">
|
||||
{JSON.stringify(node.params).substring(0, 60)}
|
||||
{JSON.stringify(node.params).length > 60 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateDescriptor((p) => ({
|
||||
...p,
|
||||
primitives: p.primitives.filter((_, idx) => idx !== i),
|
||||
}));
|
||||
if (selectedIndex === i) setSelectedIndex(null);
|
||||
else if (selectedIndex && selectedIndex > i) setSelectedIndex(selectedIndex - 1);
|
||||
}}
|
||||
title="Remove primitive"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right: Inspector */}
|
||||
<aside
|
||||
data-testid="custom-primitive-inspector"
|
||||
className="flex-[1.5] bg-neutral-50/30 overflow-y-auto p-6"
|
||||
>
|
||||
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
|
||||
Parameter Inspector
|
||||
</h3>
|
||||
{selectedIndex === null || !descriptor.primitives[selectedIndex] ? (
|
||||
<div className="flex items-center justify-center h-48 text-sm text-neutral-400 italic">
|
||||
Select a primitive to edit its parameters.
|
||||
</div>
|
||||
) : (
|
||||
<ParamField
|
||||
node={descriptor.primitives[selectedIndex]}
|
||||
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
|
||||
allPrimitives={descriptor.primitives}
|
||||
onChange={(newParams) => {
|
||||
updateDescriptor((p) => {
|
||||
const next = [...p.primitives];
|
||||
next[selectedIndex] = { kind: next[selectedIndex]!.kind, params: newParams };
|
||||
return { ...p, primitives: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Validation Status */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue