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:
19
ui/package-lock.json
generated
19
ui/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
91
ui/src/components/EnvEditor.module.css
Normal file
91
ui/src/components/EnvEditor.module.css
Normal 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;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
130
ui/src/components/env-convert.ts
Normal file
130
ui/src/components/env-convert.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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))}>×</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))}>×</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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user