Files
cameleer-server/ui/src/components/EnvEditor.tsx
hsiegeln 1a45235e30
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m12s
CI / docker (push) Successful in 1m32s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
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>
2026-04-11 08:16:09 +02:00

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)}
>
&times;
</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>
);
}