refactor(builder): extract shared ArrayItemFieldsEditor from SectionTypePanel/GenericPropsEditor

Both panels had a ~90-line near-byte-identical per-item field editor
(boolean->checkbox, number->number, /color/->color, long->textarea, else
text, fields derived from Object.keys(items[0])). Moved into a new
ArrayItemFieldsEditor component in ArrayItemFields.tsx used by both.
SectionTypePanel keeps its own FeaturesGrid special-case (key === 'features'
-> FeaturesEditor) and its empty-array guard ahead of the shared call.
Behavior-preserving (task E4.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:15:34 -07:00
parent 4733fe84b5
commit 8f51b5144a
3 changed files with 133 additions and 227 deletions
@@ -1,5 +1,4 @@
import React from 'react';
import { useEditor } from '@craftjs/core';
import {
TEXT_COLORS,
BG_COLORS,
@@ -12,20 +11,17 @@ import {
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
ArrayPropEditor,
labelStyle,
inputStyle,
smallInputStyle,
sectionGap,
useNodeProp,
} from './shared';
import { ArrayItemFieldsEditor } from './ArrayItemFields';
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
selectedId, nodeProps, typeName,
}) => {
const { actions } = useEditor();
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
@@ -98,116 +94,9 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
)}
{/* Array props */}
{arrayProps.map(([key, items]) => {
const arrayItems = items as any[];
const sampleItem = arrayItems[0] || {};
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
return (
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
<ArrayPropEditor
selectedId={selectedId}
propKey={key}
items={arrayItems}
renderItem={(item: any, index: number) => {
if (typeof item !== 'object' || item === null) {
return (
<input
type="text"
value={String(item)}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = e.target.value;
props[key] = updated;
});
}}
style={smallInputStyle}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{itemFields.map((field) => {
const fieldVal = item[field];
if (typeof fieldVal === 'boolean') {
return (
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.checked };
props[key] = updated;
});
}} />
{field}
</label>
);
}
if (typeof fieldVal === 'number') {
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
<input type="number" value={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
props[key] = updated;
});
}} style={smallInputStyle} />
</div>
);
}
if (/color/i.test(field) && typeof fieldVal === 'string') {
return (
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
</div>
);
}
const strVal = String(fieldVal ?? '');
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
{isLongField ? (
<textarea value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={smallInputStyle} />
)}
</div>
);
})}
</div>
);
}}
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
: ''
}
/>
</CollapsibleSection>
);
})}
{arrayProps.map(([key, items]) => (
<ArrayItemFieldsEditor key={key} selectedId={selectedId} propKey={key} items={items as any[]} />
))}
{/* Style controls */}
<CollapsibleSection title="Style">