feat: multi-format env var editor for deployment config
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

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:
hsiegeln
2026-04-11 08:16:09 +02:00
parent e9ce828e10
commit 1a45235e30
6 changed files with 485 additions and 36 deletions

19
ui/package-lock.json generated
View File

@@ -9,8 +9,9 @@
"version": "0.0.0",
"hasInstallScript": true,
"dependencies": {
"@cameleer/design-system": "^0.1.42",
"@cameleer/design-system": "^0.1.43",
"@tanstack/react-query": "^5.90.21",
"js-yaml": "^4.1.1",
"lucide-react": "^1.7.0",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",
@@ -23,6 +24,7 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -279,9 +281,9 @@
}
},
"node_modules/@cameleer/design-system": {
"version": "0.1.42",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.42/design-system-0.1.42.tgz",
"integrity": "sha512-Cyy+2HsbBPLKRZaSGFxMUvIwI+g8ocdjcojFTGgtq5vrpE/8IYJLgxdtM9+eDoF2Zewk7MrBzfpNqjEYlQO3ng==",
"version": "0.1.43",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.43/design-system-0.1.43.tgz",
"integrity": "sha512-eDCCFaS7DSRFRoWUWL1t256mdvIpL8XNbgRMgOWpZY7PWd162T1NESuzDn8PUAphC4jXtdLoHqQ+kihd+AA/hg==",
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0",
@@ -1137,6 +1139,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1581,7 +1590,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
@@ -2501,7 +2509,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"

View File

@@ -15,8 +15,9 @@
"postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer3-logo.svg','public/favicon.svg')\""
},
"dependencies": {
"@cameleer/design-system": "^0.1.42",
"@cameleer/design-system": "^0.1.43",
"@tanstack/react-query": "^5.90.21",
"js-yaml": "^4.1.1",
"lucide-react": "^1.7.0",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",
@@ -29,6 +30,7 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View File

@@ -0,0 +1,91 @@
.wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.actions {
margin-left: auto;
display: flex;
gap: 6px;
}
.tableBody {
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.rowKey {
width: 220px;
flex-shrink: 0;
}
.rowValue {
flex: 1;
}
.deleteBtn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
padding: 4px;
border-radius: var(--radius-sm);
transition: color 0.1s;
}
.deleteBtn:hover {
color: var(--error);
}
.deleteBtn:disabled {
opacity: 0.3;
cursor: default;
}
.textEditor {
width: 100%;
min-height: 180px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
resize: vertical;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
tab-size: 2;
}
.textEditor:focus {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.importZone {
margin-top: 4px;
}
.empty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}

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

View File

@@ -0,0 +1,130 @@
import yaml from 'js-yaml';
export type EnvEntry = { key: string; value: string };
export type Format = 'env' | 'properties' | 'yaml';
/** Convert KEY=value env pairs to Spring-style properties (lowercase, _ -> .) */
export function envToProperties(entries: EnvEntry[]): string {
return entries
.filter((e) => e.key.trim())
.map((e) => `${e.key.toLowerCase().replace(/_/g, '.')}=${e.value}`)
.join('\n');
}
/** Convert KEY=value env pairs to nested YAML */
export function envToYaml(entries: EnvEntry[]): string {
const obj: Record<string, unknown> = {};
for (const { key, value } of entries) {
if (!key.trim()) continue;
const path = key.toLowerCase().replace(/_/g, '.').split('.');
let cur: Record<string, unknown> = obj;
for (let i = 0; i < path.length - 1; i++) {
if (typeof cur[path[i]] !== 'object' || cur[path[i]] === null) {
cur[path[i]] = {};
}
cur = cur[path[i]] as Record<string, unknown>;
}
cur[path[path.length - 1]] = value;
}
return yaml.dump(obj, { lineWidth: -1, noRefs: true }).trimEnd();
}
/** Convert KEY=value env pairs to .env format */
export function envToEnvText(entries: EnvEntry[]): string {
return entries
.filter((e) => e.key.trim())
.map((e) => `${e.key}=${e.value}`)
.join('\n');
}
/** Normalize a key to UPPER_SNAKE_CASE */
function toEnvKey(key: string): string {
return key.replace(/[.\-]/g, '_').toUpperCase();
}
/** Parse .properties text to env entries */
export function parseProperties(text: string): EnvEntry[] {
const entries: EnvEntry[] = [];
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
entries.push({
key: toEnvKey(trimmed.slice(0, eq).trim()),
value: trimmed.slice(eq + 1).trim(),
});
}
return entries;
}
/** Parse YAML text to env entries (flattens nested keys) */
export function parseYaml(text: string): EnvEntry[] {
const doc = yaml.load(text);
if (!doc || typeof doc !== 'object') return [];
const entries: EnvEntry[] = [];
function walk(obj: Record<string, unknown>, prefix: string) {
for (const [k, v] of Object.entries(obj)) {
const path = prefix ? `${prefix}_${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
walk(v as Record<string, unknown>, path);
} else {
entries.push({ key: toEnvKey(path), value: String(v ?? '') });
}
}
}
walk(doc as Record<string, unknown>, '');
return entries;
}
/** Parse .env text to env entries */
export function parseEnvText(text: string): EnvEntry[] {
const entries: EnvEntry[] = [];
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
entries.push({
key: trimmed.slice(0, eq).trim(),
value: trimmed.slice(eq + 1).trim(),
});
}
return entries;
}
/** Auto-detect format from text content */
export function detectFormat(text: string): Format {
const lines = text.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
if (lines.length === 0) return 'env';
// YAML: has indented lines or lines ending with : (no =)
const hasIndent = lines.some((l) => /^\s{2,}\S/.test(l));
const hasColonNoEq = lines.some((l) => l.includes(':') && !l.includes('='));
if (hasIndent || (hasColonNoEq && !lines.some((l) => /^[A-Z_]+=/.test(l)))) return 'yaml';
// Properties: lowercase keys with dots
const hasPropertyKeys = lines.some((l) => /^[a-z][a-z0-9.\-]+=/.test(l));
if (hasPropertyKeys) return 'properties';
return 'env';
}
/** Parse text in a given format to env entries */
export function parseText(text: string, format: Format): EnvEntry[] {
switch (format) {
case 'properties': return parseProperties(text);
case 'yaml': return parseYaml(text);
case 'env': return parseEnvText(text);
}
}
/** Convert env entries to text in a given format */
export function toText(entries: EnvEntry[], format: Format): string {
switch (format) {
case 'properties': return envToProperties(entries);
case 'yaml': return envToYaml(entries);
case 'env': return envToEnvText(entries);
}
}

View File

@@ -17,6 +17,7 @@ import {
useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { EnvEditor } from '../../components/EnvEditor';
import { useEnvironmentStore } from '../../api/environment-store';
import { useEnvironments } from '../../api/queries/admin/environments';
import {
@@ -366,19 +367,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
{configTab === 'variables' && (
<div className={sectionStyles.section}>
<SectionHeader>Variables</SectionHeader>
{envVars.map((v, i) => (
<div key={i} className={styles.envVarRow}>
<Input disabled={busy} value={v.key} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next);
}} className={styles.envVarKey} placeholder="KEY" />
<Input disabled={busy} value={v.value} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
}} className={styles.envVarValue} placeholder="value" />
<button className={styles.envVarDelete} disabled={busy}
onClick={() => !busy && setEnvVars(envVars.filter((_, j) => j !== i))}>&times;</button>
</div>
))}
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
<EnvEditor value={envVars} onChange={setEnvVars} disabled={busy} />
</div>
)}
@@ -1015,22 +1004,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
{configTab === 'variables' && (
<div className={sectionStyles.section}>
<SectionHeader>Variables</SectionHeader>
{envVars.map((v, i) => (
<div key={i} className={styles.envVarRow}>
<Input disabled={!editing} value={v.key} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next);
}} className={styles.envVarKey} placeholder="KEY" />
<Input disabled={!editing} value={v.value} onChange={(e) => {
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
}} className={styles.envVarValue} placeholder="value" />
<button className={styles.envVarDelete} disabled={!editing}
onClick={() => editing && setEnvVars(envVars.filter((_, j) => j !== i))}>&times;</button>
</div>
))}
{editing && (
<Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
)}
{envVars.length === 0 && !editing && <EmptyState title="No variables" description="No environment variables configured." />}
<EnvEditor value={envVars} onChange={setEnvVars} disabled={!editing} />
</div>
)}