feat: multi-format env var editor for deployment config
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>
This commit is contained in:
245
ui/src/components/EnvEditor.tsx
Normal file
245
ui/src/components/EnvEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user