@sciphergfx/json-to-table
Version:
UI-agnostic React components for JSON to Table and JSON to Form Fields conversion with support for Chakra UI, Tailwind CSS, and shadcn/ui
545 lines (504 loc) • 17.8 kB
JSX
import React, { useState, useEffect } from 'react';
import { getUIComponents, getUIClasses } from '../utils/uiAdapters';
import { flattenObject, unflattenObject, getInputType, parseJsonSafely, getDisplayName } from '../utils/jsonUtils';
/**
* JsonToFields Component - UI Library Agnostic
* @param {Object} props - Component props
* @param {string} props.uiLibrary - UI library to use ("chakra", "tailwind", "shadcn")
* @param {Function} props.onSave - Callback when save is triggered (nestedData, flatData) => void
* @param {Function} props.onCancel - Callback when cancel is triggered () => void
* @param {Function} props.onFieldChange - Callback when field changes (key, value, fullData) => void
* @param {string} props.saveButtonText - Text for save button
* @param {string} props.cancelButtonText - Text for cancel button
* @param {string} props.initialJson - Initial JSON string
* @param {Object} props.customStyles - Custom styles object
* @param {boolean} props.showControls - Whether to show save/cancel buttons
* @param {boolean} props.showJsonInput - Whether to show JSON input textarea
* @param {number} props.columns - Number of columns for form layout (default: 1)
* @param {Object} props.fieldConfig - Field configuration for custom input types
*/
const JsonToFields = ({
uiLibrary = 'chakra',
onSave,
onCancel,
onFieldChange,
saveButtonText = 'Save Changes',
cancelButtonText = 'Reset Form',
initialJson = '',
customStyles = {},
showControls = true,
showJsonInput = true,
columns = 1,
fieldConfig = {},
...props
}) => {
const [jsonInput, setJsonInput] = useState(initialJson);
const [formData, setFormData] = useState({});
const [originalFormData, setOriginalFormData] = useState({});
const [, setParsedJson] = useState(null);
const [error, setError] = useState('');
const UI = getUIComponents(uiLibrary);
useEffect(() => {
if (initialJson) {
setJsonInput(initialJson);
parseJson(initialJson);
} else if (initialJson === '') {
// Clear everything when initialJson is explicitly set to empty string
setJsonInput('');
setFormData({});
setOriginalFormData({});
setParsedJson(null);
setError('');
}
}, [initialJson]); // parseJson is stable, no need to include
const parseJson = (jsonString = jsonInput) => {
const result = parseJsonSafely(jsonString);
if (result.success) {
setParsedJson(result.data);
const flattened = flattenObject(result.data);
setFormData(flattened);
setOriginalFormData({ ...flattened });
setError('');
} else {
setError(result.error);
setParsedJson(null);
setFormData({});
setOriginalFormData({});
}
};
const handleFieldChange = (key, value) => {
const newFormData = {
...formData,
[key]: value
};
setFormData(newFormData);
if (onFieldChange) {
const nestedData = unflattenObject(newFormData);
onFieldChange(key, value, nestedData);
}
};
const handleSave = () => {
if (onSave) {
const nestedData = unflattenObject(formData);
onSave(nestedData, formData);
}
};
const handleCancel = () => {
setFormData({ ...originalFormData });
if (onCancel) {
onCancel();
}
};
const renderFormField = (key, value) => {
const fieldTypeConfig = getInputType(value, key, fieldConfig);
const displayName = getDisplayName(key);
// Handle different field types based on configuration
switch (fieldTypeConfig.type) {
case 'checkbox':
return renderCheckboxField(key, value, displayName, fieldTypeConfig);
case 'select':
return renderSelectField(key, value, displayName, fieldTypeConfig);
case 'multi-select':
return renderMultiSelectField(key, value, displayName, fieldTypeConfig);
case 'textarea':
return renderTextareaField(key, value, displayName, fieldTypeConfig);
case 'email':
case 'url':
case 'date':
case 'password':
return renderSpecialInputField(key, value, displayName, fieldTypeConfig);
case 'number':
return renderNumberField(key, value, displayName, fieldTypeConfig);
case 'array':
return renderArrayField(key, value, displayName, fieldTypeConfig);
case 'object':
return renderObjectField(key, value, displayName, fieldTypeConfig);
default:
return renderTextInputField(key, value, displayName, fieldTypeConfig);
}
};
// Checkbox field renderer
const renderCheckboxField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', ...customStyles.label }}
>
<input
type="checkbox"
checked={formData[key] || false}
onChange={(e) => handleFieldChange(key, e.target.checked)}
className={getUIClasses(uiLibrary, 'Input', 'checkbox')}
style={customStyles.checkbox}
/>
<UI.Text
className={getUIClasses(uiLibrary, 'Text')}
style={{ fontWeight: '500', ...customStyles.fieldLabel }}
>
{displayName}
</UI.Text>
</UI.Label>
</UI.Box>
);
};
// Select field renderer
const renderSelectField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName}
</UI.Label>
<UI.Select
value={formData[key] || ''}
onChange={(e) => handleFieldChange(key, e.target.value)}
className={getUIClasses(uiLibrary, 'Select')}
style={{ width: '100%', ...customStyles.select }}
>
<option value="">Select {displayName}</option>
{fieldTypeConfig.options?.map(option => (
<option key={option} value={option}>{option}</option>
))}
</UI.Select>
</UI.Box>
);
};
// Multi-select field renderer
const renderMultiSelectField = (key, value, displayName, fieldTypeConfig) => {
const selectedValues = Array.isArray(formData[key]) ? formData[key] : [];
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName}
</UI.Label>
<UI.Box style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{fieldTypeConfig.options?.map(option => (
<UI.Label key={option} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={selectedValues.includes(option)}
onChange={(e) => {
const newValues = e.target.checked
? [...selectedValues, option]
: selectedValues.filter(v => v !== option);
handleFieldChange(key, newValues);
}}
style={customStyles.checkbox}
/>
<UI.Text style={{ fontSize: '14px', ...customStyles.text }}>{option}</UI.Text>
</UI.Label>
))}
</UI.Box>
</UI.Box>
);
};
// Textarea field renderer
const renderTextareaField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName}
</UI.Label>
<UI.Textarea
value={formData[key] || ''}
onChange={(e) => handleFieldChange(key, e.target.value)}
className={getUIClasses(uiLibrary, 'Textarea')}
style={{
width: '100%',
minHeight: `${(fieldTypeConfig.rows || 4) * 1.5}rem`,
...customStyles.textarea
}}
/>
</UI.Box>
);
};
// Special input field renderer (email, url, date, password)
const renderSpecialInputField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName}
</UI.Label>
<UI.Input
type={fieldTypeConfig.type}
value={formData[key] || ''}
onChange={(e) => handleFieldChange(key, e.target.value)}
className={getUIClasses(uiLibrary, 'Input')}
style={{ width: '100%', ...customStyles.input }}
/>
</UI.Box>
);
};
// Number field renderer
const renderNumberField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName}
</UI.Label>
<UI.Input
type="number"
value={formData[key] || ''}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
handleFieldChange(key, val);
}}
className={getUIClasses(uiLibrary, 'Input')}
style={{ width: '100%', ...customStyles.input }}
/>
</UI.Box>
);
};
// Array field renderer
const renderArrayField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName} (Array)
</UI.Label>
<UI.Textarea
value={JSON.stringify(value, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
handleFieldChange(key, parsed);
} catch {
// Keep the raw string value for now
handleFieldChange(key, e.target.value);
}
}}
className={getUIClasses(uiLibrary, 'Textarea')}
style={{
fontFamily: 'monospace',
minHeight: '100px',
width: '100%',
...customStyles.textarea
}}
/>
</UI.Box>
);
};
// Object field renderer
const renderObjectField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName} (Object)
</UI.Label>
<UI.Textarea
value={JSON.stringify(value, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
handleFieldChange(key, parsed);
} catch {
// Keep the raw string value for now
handleFieldChange(key, e.target.value);
}
}}
className={getUIClasses(uiLibrary, 'Textarea')}
style={{
fontFamily: 'monospace',
minHeight: '100px',
width: '100%',
...customStyles.textarea
}}
/>
</UI.Box>
);
};
// Text input field renderer (default)
const renderTextInputField = (key, value, displayName, fieldTypeConfig) => {
return (
<UI.Box
key={key}
className={getUIClasses(uiLibrary, 'Box')}
style={{ marginBottom: '1rem', ...customStyles.fieldContainer }}
>
<UI.Label
className={getUIClasses(uiLibrary, 'Label')}
style={{ display: 'block', fontWeight: '600', marginBottom: '0.5rem', ...customStyles.fieldLabel }}
>
{displayName}
</UI.Label>
<UI.Input
type="text"
value={formData[key] || ''}
onChange={(e) => handleFieldChange(key, e.target.value)}
className={getUIClasses(uiLibrary, 'Input')}
style={{ width: '100%', ...customStyles.input }}
/>
</UI.Box>
);
};
// Function to render form fields in columns
const renderFormFields = () => {
const formEntries = Object.entries(formData);
if (columns <= 1) {
// Single column layout (default)
return (
<UI.VStack
className={getUIClasses(uiLibrary, 'VStack')}
style={{ gap: '1rem', ...customStyles.formStack }}
>
{formEntries.map(([key, value]) => renderFormField(key, value))}
</UI.VStack>
);
}
// Multi-column layout
const fieldsPerColumn = Math.ceil(formEntries.length / columns);
const columnGroups = [];
for (let i = 0; i < columns; i++) {
const startIndex = i * fieldsPerColumn;
const endIndex = Math.min(startIndex + fieldsPerColumn, formEntries.length);
columnGroups.push(formEntries.slice(startIndex, endIndex));
}
const gridStyles = {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '2rem',
width: '100%',
...customStyles.formGrid
};
return (
<UI.Box
className={getUIClasses(uiLibrary, 'Box')}
style={gridStyles}
>
{columnGroups.map((columnFields, columnIndex) => (
<UI.VStack
key={columnIndex}
className={getUIClasses(uiLibrary, 'VStack')}
style={{ gap: '1rem', ...customStyles.formColumn }}
>
{columnFields.map(([key, value]) => renderFormField(key, value))}
</UI.VStack>
))}
</UI.Box>
);
};
return (
<UI.Container
className={getUIClasses(uiLibrary, 'Container')}
style={customStyles.container}
{...props}
>
<UI.VStack
className={getUIClasses(uiLibrary, 'VStack')}
style={{ gap: '1rem', ...customStyles.stack }}
>
{showJsonInput && (
<>
<UI.Box
className={getUIClasses(uiLibrary, 'Box')}
style={{ width: '100%', ...customStyles.inputContainer }}
>
<UI.Textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder="Paste your JSON data here..."
className={getUIClasses(uiLibrary, 'Textarea')}
style={{
minHeight: '150px',
fontFamily: 'monospace',
...customStyles.textarea
}}
/>
</UI.Box>
</>
)}
{error && (
<UI.Alert
className={getUIClasses(uiLibrary, 'Alert', 'error')}
style={customStyles.alert}
>
<UI.Text style={customStyles.errorText}>{error}</UI.Text>
</UI.Alert>
)}
{Object.keys(formData).length > 0 && (
<UI.Card
className={getUIClasses(uiLibrary, 'Card')}
style={{ width: '100%', padding: '1rem', ...customStyles.formCard }}
>
{renderFormFields()}
</UI.Card>
)}
{showControls && Object.keys(formData).length > 0 && (
<UI.HStack
className={getUIClasses(uiLibrary, 'HStack')}
style={{ gap: '0.5rem', marginTop: '1rem', ...customStyles.controlButtons }}
>
<UI.Button
onClick={handleSave}
className={getUIClasses(uiLibrary, 'Button', 'default')}
style={customStyles.saveButton}
>
{saveButtonText}
</UI.Button>
<UI.Button
onClick={handleCancel}
className={getUIClasses(uiLibrary, 'Button', 'secondary')}
style={customStyles.cancelButton}
>
{cancelButtonText}
</UI.Button>
</UI.HStack>
)}
</UI.VStack>
</UI.Container>
);
};
export default JsonToFields;