159 lines
4.3 KiB
TypeScript
159 lines
4.3 KiB
TypeScript
|
|
/**
|
||
|
|
* JsonEditor - Better UX for JSONB fields
|
||
|
|
*
|
||
|
|
* Provides syntax highlighting, validation, and error messages
|
||
|
|
* instead of raw textarea for JSON editing.
|
||
|
|
*
|
||
|
|
* Used by: QualityTemplateWizard, CustomerOrderWizard
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||
|
|
|
||
|
|
interface JsonEditorProps {
|
||
|
|
value: any;
|
||
|
|
onChange: (value: any) => void;
|
||
|
|
label?: string;
|
||
|
|
placeholder?: string;
|
||
|
|
required?: boolean;
|
||
|
|
rows?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const JsonEditor: React.FC<JsonEditorProps> = ({
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
label,
|
||
|
|
placeholder,
|
||
|
|
required = false,
|
||
|
|
rows = 6,
|
||
|
|
}) => {
|
||
|
|
const { t } = useTranslation('wizards');
|
||
|
|
const [jsonString, setJsonString] = useState('');
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [isValid, setIsValid] = useState(true);
|
||
|
|
|
||
|
|
// Initialize from value
|
||
|
|
useEffect(() => {
|
||
|
|
try {
|
||
|
|
if (value === null || value === undefined || value === '') {
|
||
|
|
setJsonString('');
|
||
|
|
} else if (typeof value === 'string') {
|
||
|
|
// Try to parse to validate
|
||
|
|
JSON.parse(value);
|
||
|
|
setJsonString(value);
|
||
|
|
} else {
|
||
|
|
setJsonString(JSON.stringify(value, null, 2));
|
||
|
|
}
|
||
|
|
setIsValid(true);
|
||
|
|
setError(null);
|
||
|
|
} catch (e) {
|
||
|
|
setJsonString(typeof value === 'string' ? value : '');
|
||
|
|
setIsValid(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleChange = (newValue: string) => {
|
||
|
|
setJsonString(newValue);
|
||
|
|
|
||
|
|
// Validate JSON
|
||
|
|
if (newValue.trim() === '') {
|
||
|
|
setError(null);
|
||
|
|
setIsValid(true);
|
||
|
|
onChange(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(newValue);
|
||
|
|
setError(null);
|
||
|
|
setIsValid(true);
|
||
|
|
onChange(parsed);
|
||
|
|
} catch (e) {
|
||
|
|
setError(e instanceof Error ? e.message : 'Invalid JSON');
|
||
|
|
setIsValid(false);
|
||
|
|
// Don't update parent with invalid JSON
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatJson = () => {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(jsonString);
|
||
|
|
const formatted = JSON.stringify(parsed, null, 2);
|
||
|
|
setJsonString(formatted);
|
||
|
|
setError(null);
|
||
|
|
setIsValid(true);
|
||
|
|
onChange(parsed);
|
||
|
|
} catch (e) {
|
||
|
|
setError(e instanceof Error ? e.message : 'Invalid JSON');
|
||
|
|
setIsValid(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
{label && (
|
||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||
|
|
{label}
|
||
|
|
{required && ' *'}
|
||
|
|
</label>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="relative">
|
||
|
|
<textarea
|
||
|
|
value={jsonString}
|
||
|
|
onChange={(e) => handleChange(e.target.value)}
|
||
|
|
placeholder={placeholder || '{\n "key": "value"\n}'}
|
||
|
|
rows={rows}
|
||
|
|
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 font-mono text-sm ${
|
||
|
|
isValid
|
||
|
|
? 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||
|
|
: 'border-red-500 focus:ring-red-500'
|
||
|
|
} bg-[var(--bg-primary)] text-[var(--text-primary)]`}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Validation indicator */}
|
||
|
|
<div className="absolute top-2 right-2">
|
||
|
|
{jsonString && isValid && (
|
||
|
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
||
|
|
)}
|
||
|
|
{jsonString && !isValid && (
|
||
|
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Error message */}
|
||
|
|
{error && (
|
||
|
|
<div className="mt-2 flex items-start gap-2 text-sm text-red-600">
|
||
|
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||
|
|
<span>{error}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Format button */}
|
||
|
|
{jsonString && (
|
||
|
|
<div className="mt-2">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={formatJson}
|
||
|
|
className="text-sm px-3 py-1 rounded transition-colors"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-tertiary)',
|
||
|
|
color: 'var(--text-secondary)',
|
||
|
|
border: '1px solid var(--border-secondary)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{t('common.actions.formatJson', 'Format JSON')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Help text */}
|
||
|
|
<p className="mt-2 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||
|
|
{t('common.hints.jsonEditor', 'Enter valid JSON. Use the format button to auto-format.')}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|