Improve frontend 5
This commit is contained in:
324
frontend/src/components/ui/KeyValueEditor/KeyValueEditor.tsx
Normal file
324
frontend/src/components/ui/KeyValueEditor/KeyValueEditor.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, Code } from 'lucide-react';
|
||||
import Tooltip from '../Tooltip/Tooltip';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
interface KeyValuePair {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
}
|
||||
|
||||
interface KeyValueEditorProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
value?: Record<string, any> | string; // Can accept JSON object or JSON string
|
||||
onChange?: (value: Record<string, any>) => void;
|
||||
placeholder?: string;
|
||||
suggestions?: Array<{ key: string; value: string; type?: 'string' | 'number' | 'boolean' }>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
|
||||
label,
|
||||
tooltip,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
suggestions = [],
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation('wizards');
|
||||
const [pairs, setPairs] = useState<KeyValuePair[]>([]);
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
const [rawJson, setRawJson] = useState('');
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
// Initialize pairs from value
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setPairs([]);
|
||||
setRawJson('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let jsonObj: Record<string, any>;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value.trim() === '') {
|
||||
setPairs([]);
|
||||
setRawJson('{}');
|
||||
return;
|
||||
}
|
||||
jsonObj = JSON.parse(value);
|
||||
} else {
|
||||
jsonObj = value;
|
||||
}
|
||||
|
||||
const newPairs: KeyValuePair[] = Object.entries(jsonObj).map(([key, val], index) => ({
|
||||
id: `pair-${Date.now()}-${index}`,
|
||||
key,
|
||||
value: String(val),
|
||||
type: detectType(val)
|
||||
}));
|
||||
|
||||
setPairs(newPairs);
|
||||
setRawJson(JSON.stringify(jsonObj, null, 2));
|
||||
setJsonError(null);
|
||||
} catch (error) {
|
||||
// If parsing fails, treat as empty
|
||||
setPairs([]);
|
||||
setRawJson(typeof value === 'string' ? value : '{}');
|
||||
setJsonError('Invalid JSON');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const detectType = (value: any): 'string' | 'number' | 'boolean' => {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
return 'string';
|
||||
};
|
||||
|
||||
const convertValue = (value: string, type: 'string' | 'number' | 'boolean'): any => {
|
||||
if (type === 'boolean') {
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
if (type === 'number') {
|
||||
const num = parseFloat(value);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const pairsToJson = (pairs: KeyValuePair[]): Record<string, any> => {
|
||||
const obj: Record<string, any> = {};
|
||||
pairs.forEach(pair => {
|
||||
if (pair.key.trim()) {
|
||||
obj[pair.key.trim()] = convertValue(pair.value, pair.type);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
|
||||
const handleAddPair = () => {
|
||||
const newPair: KeyValuePair = {
|
||||
id: `pair-${Date.now()}`,
|
||||
key: '',
|
||||
value: '',
|
||||
type: 'string'
|
||||
};
|
||||
const newPairs = [...pairs, newPair];
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
};
|
||||
|
||||
const handleRemovePair = (id: string) => {
|
||||
const newPairs = pairs.filter(p => p.id !== id);
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
};
|
||||
|
||||
const handlePairChange = (id: string, field: 'key' | 'value' | 'type', newValue: string) => {
|
||||
const newPairs = pairs.map(pair => {
|
||||
if (pair.id === id) {
|
||||
return { ...pair, [field]: newValue };
|
||||
}
|
||||
return pair;
|
||||
});
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
};
|
||||
|
||||
const handleApplySuggestion = (suggestion: { key: string; value: string; type?: 'string' | 'number' | 'boolean' }) => {
|
||||
// Check if key already exists
|
||||
const existingPair = pairs.find(p => p.key === suggestion.key);
|
||||
if (existingPair) {
|
||||
// Update existing
|
||||
handlePairChange(existingPair.id, 'value', suggestion.value);
|
||||
if (suggestion.type) {
|
||||
handlePairChange(existingPair.id, 'type', suggestion.type);
|
||||
}
|
||||
} else {
|
||||
// Add new
|
||||
const newPair: KeyValuePair = {
|
||||
id: `pair-${Date.now()}`,
|
||||
key: suggestion.key,
|
||||
value: suggestion.value,
|
||||
type: suggestion.type || 'string'
|
||||
};
|
||||
const newPairs = [...pairs, newPair];
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRawJsonChange = (jsonString: string) => {
|
||||
setRawJson(jsonString);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
setJsonError(null);
|
||||
|
||||
const newPairs: KeyValuePair[] = Object.entries(parsed).map(([key, val], index) => ({
|
||||
id: `pair-${Date.now()}-${index}`,
|
||||
key,
|
||||
value: String(val),
|
||||
type: detectType(val)
|
||||
}));
|
||||
|
||||
setPairs(newPairs);
|
||||
onChange?.(parsed);
|
||||
} catch (error) {
|
||||
setJsonError('Invalid JSON format');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)]">
|
||||
{label}
|
||||
{tooltip && (
|
||||
<Tooltip content={tooltip}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRawJson(!showRawJson)}
|
||||
className="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Code className="w-3 h-3" />
|
||||
{showRawJson ? t('keyValueEditor.showBuilder') : t('keyValueEditor.showJson')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRawJson ? (
|
||||
/* Raw JSON Editor */
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={rawJson}
|
||||
onChange={(e) => handleRawJsonChange(e.target.value)}
|
||||
placeholder={placeholder || '{}'}
|
||||
rows={6}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs ${
|
||||
jsonError ? 'border-red-500' : 'border-[var(--border-secondary)]'
|
||||
}`}
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-xs text-red-500">{jsonError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Key-Value Builder */
|
||||
<div className="space-y-2">
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && pairs.length === 0 && (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg p-3">
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-2">
|
||||
{t('keyValueEditor.suggestions')}:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleApplySuggestion(suggestion)}
|
||||
className="text-xs px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded hover:border-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
{suggestion.key}: {suggestion.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key-Value Pairs */}
|
||||
{pairs.length > 0 && (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{pairs.map((pair) => (
|
||||
<div key={pair.id} className="flex items-center gap-2">
|
||||
{/* Key Input */}
|
||||
<input
|
||||
type="text"
|
||||
value={pair.key}
|
||||
onChange={(e) => handlePairChange(pair.id, 'key', e.target.value)}
|
||||
placeholder={t('keyValueEditor.keyPlaceholder')}
|
||||
className="flex-1 px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
|
||||
{/* Value Input */}
|
||||
{pair.type === 'boolean' ? (
|
||||
<select
|
||||
value={pair.value}
|
||||
onChange={(e) => handlePairChange(pair.id, 'value', e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={pair.type === 'number' ? 'number' : 'text'}
|
||||
value={pair.value}
|
||||
onChange={(e) => handlePairChange(pair.id, 'value', e.target.value)}
|
||||
placeholder={t('keyValueEditor.valuePlaceholder')}
|
||||
step={pair.type === 'number' ? '0.01' : undefined}
|
||||
className="flex-1 px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type Selector */}
|
||||
<select
|
||||
value={pair.type}
|
||||
onChange={(e) => handlePairChange(pair.id, 'type', e.target.value)}
|
||||
className="w-24 px-2 py-1.5 text-xs border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-secondary)]"
|
||||
>
|
||||
<option value="string">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Bool</option>
|
||||
</select>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePair(pair.id)}
|
||||
className="p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title={t('keyValueEditor.remove')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPair}
|
||||
className="w-full py-2 border-2 border-dashed border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors font-medium text-sm inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('keyValueEditor.addPair')}
|
||||
</button>
|
||||
|
||||
{/* Empty State */}
|
||||
{pairs.length === 0 && suggestions.length === 0 && (
|
||||
<p className="text-xs text-center text-[var(--text-tertiary)] py-4">
|
||||
{t('keyValueEditor.emptyState')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyValueEditor;
|
||||
2
frontend/src/components/ui/KeyValueEditor/index.ts
Normal file
2
frontend/src/components/ui/KeyValueEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { KeyValueEditor } from './KeyValueEditor';
|
||||
export default from './KeyValueEditor';
|
||||
Reference in New Issue
Block a user