feat(ui): custom modifiers in modifier profile panels
T3 Wave 4 (T26). PerTypePanel and PerInstancePanel kind dropdowns now
include the user's custom modifier library alongside built-ins.
- Kind dropdown uses <optgroup> separation: 'Built-in' (MODIFIER_REGISTRY)
+ 'Custom (from library)' (loadCustomModifierLibrary).
- Selecting a custom kind hides the per-instance value input and shows
a small summary card ('Custom modifier — N primitives. Edit it in
the Custom Modifier editor.') — the descriptor's primitive list IS
the payload; per-instance value is null.
- Custom kinds skip Zod-schema-based validation (they have no
per-instance value to validate); isValid is true once a custom
kind is selected.
- handleSave / handleAdd branches on isCustomKind: built-in path
preserves T1/T2 behaviour exactly; custom path emits {kind: id,
pieceType, color, value: null}.
- PerInstancePanel mirrors the same pattern in AddModifierForm.
E2E tests for the new UI defer to T29; existing 1378 unit tests pass.
This commit is contained in:
parent
cbe4a4b5f6
commit
9b586b83b5
2 changed files with 135 additions and 27 deletions
|
|
@ -11,11 +11,15 @@
|
|||
* 3. For the selected square → view existing instance modifiers and
|
||||
* add/delete them via the right-hand sidebar form.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { InstanceModifier, ModifierKindId } from '../modifiers/types';
|
||||
import type { StartingLayout } from '../layouts/types';
|
||||
import { squareToAlgebraic } from '../coord';
|
||||
import { LayoutBoardView } from './LayoutEditor';
|
||||
import {
|
||||
loadCustomModifierLibrary,
|
||||
type SavedCustomModifier,
|
||||
} from '../modifiers/custom/library.js';
|
||||
import type { ModifierClipboard } from './ModifierProfileEditor.js';
|
||||
|
||||
export interface PerInstancePanelProps {
|
||||
|
|
@ -264,10 +268,29 @@ function AddModifierForm({
|
|||
}) {
|
||||
const [kind, setKind] = useState<ModifierKindId>('hp-bonus');
|
||||
const [rawValue, setRawValue] = useState<string>('0');
|
||||
// Custom modifiers from the user's library appear in the kind
|
||||
// dropdown alongside built-ins (T26). Reload on mount; new entries
|
||||
// saved via CustomModifierEditor become visible the next time the
|
||||
// user opens this form.
|
||||
const [customs, setCustoms] = useState<SavedCustomModifier[]>(() =>
|
||||
loadCustomModifierLibrary(),
|
||||
);
|
||||
useEffect(() => {
|
||||
setCustoms(loadCustomModifierLibrary());
|
||||
}, []);
|
||||
|
||||
const customDescriptor = customs.find((c) => c.id === kind);
|
||||
const isCustomKind = customDescriptor !== undefined;
|
||||
|
||||
function handleAdd() {
|
||||
let parsed: unknown;
|
||||
if (isCustomKind && customDescriptor) {
|
||||
// Custom kinds carry no per-instance value; the descriptor's
|
||||
// primitive list is the entire payload.
|
||||
onAdd({ kind: customDescriptor.id, square, value: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
if (kind === 'direction-additions') {
|
||||
parsed = rawValue
|
||||
.split(',')
|
||||
|
|
@ -301,14 +324,41 @@ function AddModifierForm({
|
|||
}}
|
||||
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{MODIFIER_KINDS.map(({ id, label }) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="Built-in">
|
||||
{MODIFIER_KINDS.map(({ id, label }) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{customs.length > 0 && (
|
||||
<optgroup label="Custom (from library)">
|
||||
{customs.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
data-testid={`instance-kind-custom-${c.id}`}
|
||||
>
|
||||
{c.descriptor.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<ValueInput kind={kind} value={rawValue} onChange={setRawValue} />
|
||||
{isCustomKind && customDescriptor !== undefined ? (
|
||||
<div
|
||||
data-testid="instance-custom-modifier-summary"
|
||||
className="px-3 py-2 text-xs text-neutral-600 bg-neutral-50 border border-neutral-200 rounded"
|
||||
>
|
||||
Custom modifier — {customDescriptor.descriptor.primitives.length}{' '}
|
||||
primitive
|
||||
{customDescriptor.descriptor.primitives.length !== 1 ? 's' : ''}.
|
||||
Edit it in the Custom Modifier editor.
|
||||
</div>
|
||||
) : (
|
||||
<ValueInput kind={kind} value={rawValue} onChange={setRawValue} />
|
||||
)}
|
||||
|
||||
<button
|
||||
data-testid="instance-modifier-add"
|
||||
|
|
|
|||
|
|
@ -11,10 +11,14 @@
|
|||
* "direction-set" → placeholder (T21+)
|
||||
* "capture-flags" → placeholder (T21+)
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MODIFIER_REGISTRY } from '../modifiers/index.js';
|
||||
import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js';
|
||||
import type { PieceType, PieceColor } from '../schema.js';
|
||||
import {
|
||||
loadCustomModifierLibrary,
|
||||
type SavedCustomModifier,
|
||||
} from '../modifiers/custom/library.js';
|
||||
import type { ModifierClipboard } from './ModifierProfileEditor.js';
|
||||
|
||||
const PIECE_TYPES: PieceType[] = [
|
||||
|
|
@ -63,15 +67,28 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
|
|||
const [color, setColor] = useState<PieceColor | 'both'>('both');
|
||||
|
||||
const descriptors = MODIFIER_REGISTRY.list();
|
||||
// Custom modifiers from the user's library appear in the kind
|
||||
// dropdown alongside built-ins (T26). Re-loaded when the form
|
||||
// opens so newly-saved custom modifiers show up immediately.
|
||||
const [customs, setCustoms] = useState<SavedCustomModifier[]>(() =>
|
||||
loadCustomModifierLibrary(),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (formOpen) setCustoms(loadCustomModifierLibrary());
|
||||
}, [formOpen]);
|
||||
const [kindId, setKindId] = useState<string>(descriptors[0]?.id ?? '');
|
||||
const [rawValue, setRawValue] = useState('');
|
||||
|
||||
const descriptor = descriptors.find((d) => d.id === kindId);
|
||||
const customDescriptor = customs.find((c) => c.id === kindId);
|
||||
const isCustomKind = customDescriptor !== undefined;
|
||||
|
||||
const parsedValue = descriptor ? parseFormValue(descriptor, rawValue) : undefined;
|
||||
const isValid =
|
||||
descriptor !== undefined &&
|
||||
parsedValue !== undefined &&
|
||||
descriptor.valueSchema.safeParse(parsedValue).success;
|
||||
const isValid = isCustomKind
|
||||
? true // custom kinds carry no per-instance value; descriptor is applied as-is.
|
||||
: descriptor !== undefined &&
|
||||
parsedValue !== undefined &&
|
||||
descriptor.valueSchema.safeParse(parsedValue).success;
|
||||
|
||||
function resetForm() {
|
||||
setPieceType('pawn');
|
||||
|
|
@ -81,13 +98,27 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
|
|||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!isValid || !descriptor) return;
|
||||
onAdd({
|
||||
kind: descriptor.id,
|
||||
pieceType,
|
||||
color,
|
||||
value: parsedValue,
|
||||
});
|
||||
if (!isValid) return;
|
||||
if (isCustomKind && customDescriptor) {
|
||||
// Custom modifiers don't take a per-instance value; the descriptor's
|
||||
// primitive list is the entire payload. Pass `null` so the apply-time
|
||||
// dispatcher (T22) sees a present-but-meaningless value.
|
||||
onAdd({
|
||||
kind: customDescriptor.id,
|
||||
pieceType,
|
||||
color,
|
||||
value: null,
|
||||
});
|
||||
} else if (descriptor) {
|
||||
onAdd({
|
||||
kind: descriptor.id,
|
||||
pieceType,
|
||||
color,
|
||||
value: parsedValue,
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setFormOpen(false);
|
||||
resetForm();
|
||||
}
|
||||
|
|
@ -229,16 +260,32 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
|
|||
}}
|
||||
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900"
|
||||
>
|
||||
{descriptors.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="Built-in">
|
||||
{descriptors.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{customs.length > 0 && (
|
||||
<optgroup label="Custom (from library)">
|
||||
{customs.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
data-testid={`kind-custom-${c.id}`}
|
||||
>
|
||||
{c.descriptor.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Value input — rendered by uiForm type */}
|
||||
{descriptor !== undefined && (
|
||||
{/* Value input — rendered by uiForm type. Custom kinds skip
|
||||
this entirely; the descriptor's primitive list is the payload. */}
|
||||
{!isCustomKind && descriptor !== undefined && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">
|
||||
Value
|
||||
|
|
@ -250,6 +297,17 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCustomKind && customDescriptor !== undefined && (
|
||||
<div
|
||||
data-testid="custom-modifier-summary"
|
||||
className="px-3 py-2 text-xs text-neutral-600 bg-neutral-50 border border-neutral-200 rounded"
|
||||
>
|
||||
Custom modifier — {customDescriptor.descriptor.primitives.length}{' '}
|
||||
primitive
|
||||
{customDescriptor.descriptor.primitives.length !== 1 ? 's' : ''}.
|
||||
Edit it in the Custom Modifier editor.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue