Improve frontend 5

This commit is contained in:
Urtzi Alfaro
2025-11-20 19:14:49 +01:00
parent 29e6ddcea9
commit 4433b66f25
30 changed files with 3649 additions and 600 deletions

View 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;

View File

@@ -0,0 +1,2 @@
export { KeyValueEditor } from './KeyValueEditor';
export default from './KeyValueEditor';