Replace simple key-value rows with EnvEditor component that supports editing variables as Table, Properties, YAML, or .env format. Switching views converts data seamlessly. Includes file import (drag-and-drop .properties/.yaml/.env) with auto-detect and merge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
246 lines
7.3 KiB
TypeScript
246 lines
7.3 KiB
TypeScript
import { useState, useMemo, useCallback, useRef } from 'react';
|
|
import { Button, Input, SegmentedTabs, FileInput } from '@cameleer/design-system';
|
|
import type { FileInputHandle } from '@cameleer/design-system';
|
|
import { Plus, Copy, Trash2, FileUp } from 'lucide-react';
|
|
import styles from './EnvEditor.module.css';
|
|
import {
|
|
type EnvEntry,
|
|
type Format,
|
|
parseText,
|
|
toText,
|
|
detectFormat,
|
|
} from './env-convert';
|
|
|
|
interface EnvEditorProps {
|
|
value: EnvEntry[];
|
|
onChange: (entries: EnvEntry[]) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const FORMAT_TABS = [
|
|
{ label: 'Table', value: 'table' as const },
|
|
{ label: 'Properties', value: 'properties' as const },
|
|
{ label: 'YAML', value: 'yaml' as const },
|
|
{ label: '.env', value: 'env' as const },
|
|
];
|
|
|
|
type ViewMode = 'table' | Format;
|
|
|
|
export function EnvEditor({ value, onChange, disabled }: EnvEditorProps) {
|
|
const [view, setView] = useState<ViewMode>('table');
|
|
const [textDraft, setTextDraft] = useState<string | null>(null);
|
|
const [showImport, setShowImport] = useState(false);
|
|
const fileRef = useRef<FileInputHandle>(null);
|
|
|
|
// When switching views, convert between formats
|
|
const handleViewChange = useCallback(
|
|
(next: string) => {
|
|
const nextView = next as ViewMode;
|
|
|
|
// If leaving a text view, commit the draft
|
|
if (textDraft !== null && view !== 'table') {
|
|
const parsed = parseText(textDraft, view as Format);
|
|
onChange(parsed);
|
|
}
|
|
|
|
// If entering a text view, generate text from current entries
|
|
if (nextView !== 'table') {
|
|
setTextDraft(toText(value, nextView as Format));
|
|
} else {
|
|
setTextDraft(null);
|
|
}
|
|
|
|
setView(nextView);
|
|
},
|
|
[view, textDraft, value, onChange],
|
|
);
|
|
|
|
// Text for current format (memoized from entries when no draft)
|
|
const displayText = useMemo(() => {
|
|
if (textDraft !== null) return textDraft;
|
|
if (view === 'table') return '';
|
|
return toText(value, view as Format);
|
|
}, [textDraft, value, view]);
|
|
|
|
// Commit text on blur
|
|
const handleTextBlur = useCallback(() => {
|
|
if (textDraft !== null && view !== 'table') {
|
|
const parsed = parseText(textDraft, view as Format);
|
|
onChange(parsed);
|
|
}
|
|
}, [textDraft, view, onChange]);
|
|
|
|
// Table row handlers
|
|
const updateRow = useCallback(
|
|
(i: number, field: 'key' | 'value', val: string) => {
|
|
const next = [...value];
|
|
next[i] = { ...next[i], [field]: val };
|
|
onChange(next);
|
|
},
|
|
[value, onChange],
|
|
);
|
|
|
|
const deleteRow = useCallback(
|
|
(i: number) => onChange(value.filter((_, j) => j !== i)),
|
|
[value, onChange],
|
|
);
|
|
|
|
const addRow = useCallback(
|
|
() => onChange([...value, { key: '', value: '' }]),
|
|
[value, onChange],
|
|
);
|
|
|
|
// Copy to clipboard
|
|
const handleCopy = useCallback(() => {
|
|
const text = view === 'table' ? toText(value, 'env') : displayText;
|
|
navigator.clipboard.writeText(text);
|
|
}, [view, value, displayText]);
|
|
|
|
// Clear all
|
|
const handleClear = useCallback(() => {
|
|
onChange([]);
|
|
setTextDraft(view !== 'table' ? '' : null);
|
|
}, [view, onChange]);
|
|
|
|
// File import
|
|
const handleImportFile = useCallback(() => {
|
|
const file = fileRef.current?.file;
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const text = reader.result as string;
|
|
const format = detectFormat(text);
|
|
const parsed = parseText(text, format);
|
|
// Merge with existing (dedupe by key, new values win)
|
|
const existing = new Map(value.map((e) => [e.key, e]));
|
|
for (const entry of parsed) {
|
|
existing.set(entry.key, entry);
|
|
}
|
|
const merged = Array.from(existing.values());
|
|
onChange(merged);
|
|
fileRef.current?.clear();
|
|
setShowImport(false);
|
|
// If in text view, update the draft
|
|
if (view !== 'table') {
|
|
setTextDraft(toText(merged, view as Format));
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}, [value, onChange, view]);
|
|
|
|
return (
|
|
<div className={styles.wrap}>
|
|
<div className={styles.toolbar}>
|
|
<SegmentedTabs
|
|
tabs={FORMAT_TABS}
|
|
active={view}
|
|
onChange={handleViewChange}
|
|
/>
|
|
<div className={styles.actions}>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={disabled}
|
|
onClick={() => setShowImport(!showImport)}
|
|
title="Import from file"
|
|
>
|
|
<FileUp size={14} />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleCopy}
|
|
title="Copy to clipboard"
|
|
>
|
|
<Copy size={14} />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={disabled}
|
|
onClick={handleClear}
|
|
title="Clear all"
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{showImport && (
|
|
<div className={styles.importZone}>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<FileInput
|
|
ref={fileRef}
|
|
accept=".properties,.yml,.yaml,.env,.cfg,.txt"
|
|
placeholder="Drop .properties, .yaml, or .env file"
|
|
icon={<FileUp size={16} />}
|
|
/>
|
|
</div>
|
|
<Button size="sm" variant="primary" onClick={handleImportFile}>
|
|
Import
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{view === 'table' ? (
|
|
<div className={styles.tableBody}>
|
|
{value.map((entry, i) => (
|
|
<div key={i} className={styles.row}>
|
|
<Input
|
|
disabled={disabled}
|
|
value={entry.key}
|
|
onChange={(e) => updateRow(i, 'key', e.target.value)}
|
|
className={styles.rowKey}
|
|
placeholder="KEY"
|
|
/>
|
|
<Input
|
|
disabled={disabled}
|
|
value={entry.value}
|
|
onChange={(e) => updateRow(i, 'value', e.target.value)}
|
|
className={styles.rowValue}
|
|
placeholder="value"
|
|
/>
|
|
<button
|
|
className={styles.deleteBtn}
|
|
disabled={disabled}
|
|
onClick={() => deleteRow(i)}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
))}
|
|
{value.length === 0 && !disabled && (
|
|
<div className={styles.empty}>
|
|
No variables configured. Add one or import from a file.
|
|
</div>
|
|
)}
|
|
{!disabled && (
|
|
<Button size="sm" variant="secondary" onClick={addRow}>
|
|
<Plus size={14} style={{ marginRight: 4 }} />
|
|
Add Variable
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
className={styles.textEditor}
|
|
value={displayText}
|
|
onChange={(e) => setTextDraft(e.target.value)}
|
|
onBlur={handleTextBlur}
|
|
disabled={disabled}
|
|
spellCheck={false}
|
|
placeholder={
|
|
view === 'yaml'
|
|
? 'spring:\n datasource:\n url: jdbc:...'
|
|
: view === 'properties'
|
|
? 'server.port=8080\nspring.datasource.url=jdbc:...'
|
|
: 'SERVER_PORT=8080\nSPRING_DATASOURCE_URL=jdbc:...'
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|