refactor: complete application rewrite with modern UI
This commit is contained in:
@@ -1,41 +1,57 @@
|
||||
import { Env } from "../models/Env";
|
||||
import { ConfigReader } from "../models/ConfigReader";
|
||||
import { Config } from "../models/Config";
|
||||
import { ConfigBuilder } from "../builders/ConfigBuilder";
|
||||
import { useRef } from 'react';
|
||||
import { Upload, Download, FilePlus, File } from 'lucide-react';
|
||||
import { Button } from '../components/ui';
|
||||
import { Config } from '../models/Config';
|
||||
import { ConfigReader } from '../models/ConfigReader';
|
||||
import { ConfigBuilder } from '../builders/ConfigBuilder';
|
||||
import { Env } from '../models/Env';
|
||||
|
||||
export function FileChooser(props: { onSelected: (x: Config) => void, config?: Config }) {
|
||||
async function handleFile(x: React.ChangeEvent<HTMLInputElement>) {
|
||||
let file = x.target.files![0];
|
||||
interface FileChooserProps {
|
||||
onSelected: (config: Config) => void;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
console.log(file.name, file.type, file.size, "supported:", ConfigReader.isSupportedFormat(file));
|
||||
let reader = new ConfigReader();
|
||||
let cfg = await reader.parseFromFile(file);
|
||||
export function FileChooser({ onSelected, config }: FileChooserProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
console.log(file.name, file.type, file.size, 'supported:', ConfigReader.isSupportedFormat(file));
|
||||
|
||||
const reader = new ConfigReader();
|
||||
const cfg = await reader.parseFromFile(file);
|
||||
|
||||
if (cfg !== null) {
|
||||
props.onSelected(cfg);
|
||||
onSelected(cfg);
|
||||
}
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleNew(){
|
||||
let cfg = new Config();
|
||||
cfg.addEnvs([new Env(0, "DEFAULT", [])]);
|
||||
cfg.addTemplate("{}");
|
||||
props.onSelected(cfg);
|
||||
function handleNew() {
|
||||
const cfg = new Config();
|
||||
cfg.setEnvs([new Env(0, 'DEFAULT', [])]);
|
||||
cfg.setTemplate('{}');
|
||||
onSelected(cfg);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!props.config) {
|
||||
alert("No configuration loaded");
|
||||
if (!config) {
|
||||
alert('No configuration loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const xmlContent = ConfigBuilder.buildFullXml(props.config);
|
||||
const xmlContent = ConfigBuilder.buildFullXml(config);
|
||||
const filename = ConfigBuilder.generateFilename();
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([xmlContent], { type: "text/xml" });
|
||||
|
||||
const blob = new Blob([xmlContent], { type: 'text/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
@@ -45,25 +61,60 @@ export function FileChooser(props: { onSelected: (x: Config) => void, config?: C
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-2">
|
||||
<button className="btn btn-primary" onClick={handleNew} >Create new</button>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleDownload}
|
||||
disabled={!props.config}
|
||||
title="Download full config template"
|
||||
>
|
||||
⬇ Download
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-1">or</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
|
||||
<File className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-xl text-slate-800">Configucci</span>
|
||||
</div>
|
||||
|
||||
<div className="col">
|
||||
<input className="form-control" type="file" id="formFile" onChange={handleFile} />
|
||||
</div >
|
||||
</>
|
||||
<div className="h-8 w-px bg-slate-200" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleNew}
|
||||
icon={FilePlus}
|
||||
size="sm"
|
||||
>
|
||||
New Config
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={handleDownload}
|
||||
icon={Download}
|
||||
size="sm"
|
||||
disabled={!config}
|
||||
title="Download full config template"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<span className="text-slate-400 text-sm">or</span>
|
||||
|
||||
{/* File Upload */}
|
||||
<div className="flex-1">
|
||||
<label
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 border-2 border-dashed border-slate-300 rounded-lg cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all duration-200"
|
||||
>
|
||||
<Upload className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-600">Upload XML Config</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".xml,text/xml"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Highlight from 'react-highlight';
|
||||
import 'highlight.js/styles/far.css';
|
||||
import { Config } from "../../models/Config";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Pencil, Save, XCircle, CheckCircle } from 'lucide-react';
|
||||
import { Button, CodeBlock, Badge } from '../../components/ui';
|
||||
import { Config } from '../../models/Config';
|
||||
|
||||
interface ConfigTemplateProps {
|
||||
interface ConfigTemplateEditorProps {
|
||||
config: Config;
|
||||
onSaved: (newContent: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigTemplate(props: ConfigTemplateProps) {
|
||||
export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorProps) {
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||
const [draftContent, setDraftContent] = useState(props.config.template.content);
|
||||
const [originalContent, setOriginalContent] = useState(props.config.template.content);
|
||||
const [draftContent, setDraftContent] = useState(config.template.content);
|
||||
const [originalContent, setOriginalContent] = useState(config.template.content);
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
// Sync draft when config changes (only in view mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'view') {
|
||||
setDraftContent(props.config.template.content);
|
||||
setDraftContent(config.template.content);
|
||||
}
|
||||
}, [props.config.template.content, mode]);
|
||||
}, [config.template.content, mode]);
|
||||
|
||||
function handleEdit() {
|
||||
setOriginalContent(props.config.template.content);
|
||||
setDraftContent(props.config.template.content);
|
||||
setOriginalContent(config.template.content);
|
||||
setDraftContent(config.template.content);
|
||||
setJsonError(null);
|
||||
setMode('edit');
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function ConfigTemplate(props: ConfigTemplateProps) {
|
||||
const sanitizedValue = draftContent.replace(/@[^@]+@/g, '1');
|
||||
JSON.parse(sanitizedValue);
|
||||
setJsonError(null);
|
||||
props.onSaved(draftContent);
|
||||
onSaved(draftContent);
|
||||
setMode('view');
|
||||
} catch (e) {
|
||||
setJsonError((e as Error).message);
|
||||
@@ -52,10 +52,7 @@ export function ConfigTemplate(props: ConfigTemplateProps) {
|
||||
// Validate JSON on every change
|
||||
try {
|
||||
if (value.trim()) {
|
||||
// Replace @placeholders@ with valid JSON values for validation
|
||||
// Strategy: Replace ALL @...@ patterns with "1" (valid for both string and numeric contexts)
|
||||
const sanitizedValue = value.replace(/@[^@]+@/g, '1');
|
||||
|
||||
JSON.parse(sanitizedValue);
|
||||
setJsonError(null);
|
||||
} else {
|
||||
@@ -73,11 +70,11 @@ export function ConfigTemplate(props: ConfigTemplateProps) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
|
||||
// Insert 2 spaces at cursor position
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
||||
handleDraftChange(newValue);
|
||||
|
||||
|
||||
// Move cursor after the inserted spaces
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
@@ -88,57 +85,86 @@ export function ConfigTemplate(props: ConfigTemplateProps) {
|
||||
const isValidJson = jsonError === null;
|
||||
|
||||
return (
|
||||
<div className="config-template-editor">
|
||||
<div className="config-template-editor animate-fade-in">
|
||||
{mode === 'view' ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<button className="btn btn-primary btn-sm" onClick={handleEdit}>
|
||||
✎ Edit
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success">View Mode</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleEdit}
|
||||
icon={Pencil}
|
||||
>
|
||||
Edit Template
|
||||
</Button>
|
||||
</div>
|
||||
<Highlight className="language-json">
|
||||
{props.config.template.content || "{}"}
|
||||
</Highlight>
|
||||
</>
|
||||
|
||||
<CodeBlock code={config.template.content || '{}'} language="json" maxHeight="500px" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 d-flex gap-2 align-items-center">
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isValidJson}
|
||||
icon={Save}
|
||||
>
|
||||
✓ Save
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRevert}
|
||||
icon={XCircle}
|
||||
>
|
||||
× Revert
|
||||
</button>
|
||||
<span className={`ms-2 ${isValidJson ? 'text-success' : 'text-danger'}`}>
|
||||
{isValidJson ? 'Valid JSON' : 'Invalid JSON'}
|
||||
</span>
|
||||
Revert
|
||||
</Button>
|
||||
|
||||
<Badge variant={isValidJson ? 'success' : 'danger'}>
|
||||
{isValidJson ? (
|
||||
<>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Valid JSON
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Invalid JSON
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{jsonError && (
|
||||
<div className="alert alert-danger py-1 px-2 mb-2" style={{ fontSize: '0.875rem' }}>
|
||||
{jsonError}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-sm text-red-700 font-mono">{jsonError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className={`form-control font-monospace ${isValidJson ? 'border-success' : 'border-danger'}`}
|
||||
className={`
|
||||
w-full p-3 font-mono text-sm rounded-lg border-2
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-all duration-200
|
||||
${isValidJson
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-red-300 bg-red-50'
|
||||
}
|
||||
`}
|
||||
value={draftContent}
|
||||
onChange={(e) => handleDraftChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={20}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre',
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
style={{ whiteSpace: 'pre', overflowX: 'auto' }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,157 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import { Env } from "../../models/Env";
|
||||
import Highlight from 'react-highlight'
|
||||
import 'highlight.js/styles/far.css'
|
||||
import { Builder } from "../../builders";
|
||||
import { Config } from "../../models/Config";
|
||||
import { ConfigTemplate } from "./ConfigTemplate";
|
||||
import { useState } from 'react';
|
||||
import { Tabs, TabPanel, CodeBlock } from '../../components/ui';
|
||||
import { Env } from '../../models/Env';
|
||||
import { Config } from '../../models/Config';
|
||||
import { ConfigTemplateEditor } from './ConfigTemplate';
|
||||
import { Builder } from '../../builders';
|
||||
|
||||
|
||||
export function Content(props: { config: Config, env: Env, onTemplateSaved: (newContent: string) => void }) {
|
||||
const [selectTab, setTab] = useState(ContentType.Env);
|
||||
|
||||
// Validate placeholders for warning badge
|
||||
const missingPlaceholders = props.config.validatePlaceholders();
|
||||
const hasValidationWarnings = missingPlaceholders.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentTabs onSelected={(id) => setTab(id)} selectedTab={selectTab} hasValidationWarnings={hasValidationWarnings} />
|
||||
<div className="">
|
||||
{selectTab == ContentType.Env ? (<ContentParams env={props.env} />) : ""}
|
||||
{selectTab == ContentType.Json ? (<ConfigTemplate config={props.config} onSaved={props.onTemplateSaved} />) : ""}
|
||||
{selectTab == ContentType.Raw ? (<ContentRaw config={props.config} env={props.env} />) : ""}
|
||||
{selectTab == ContentType.Test ? (<ContentTest config={props.config} env={props.env} />) : ""}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
interface ContentProps {
|
||||
config: Config;
|
||||
env: Env;
|
||||
onTemplateSaved: (newContent: string) => void;
|
||||
}
|
||||
|
||||
enum ContentType {
|
||||
Env = 0,
|
||||
Json = 1,
|
||||
Raw = 2,
|
||||
Test = 3
|
||||
export function Content({ config, env, onTemplateSaved }: ContentProps) {
|
||||
const [activeTab, setActiveTab] = useState('env');
|
||||
|
||||
// Validate placeholders for warning badge
|
||||
const missingPlaceholders = config.validatePlaceholders();
|
||||
const hasValidationWarnings = missingPlaceholders.length > 0;
|
||||
|
||||
const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [
|
||||
{ id: 'env', label: 'Env' },
|
||||
{
|
||||
id: 'template',
|
||||
label: 'Content Template',
|
||||
badge: hasValidationWarnings ? '!' : undefined,
|
||||
badgeVariant: hasValidationWarnings ? 'warning' : undefined,
|
||||
},
|
||||
{ id: 'raw', label: 'Raw Template' },
|
||||
{ id: 'test', label: 'Test-filled' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
|
||||
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<div className="p-4">
|
||||
<TabPanel isActive={activeTab === 'env'}>
|
||||
<ContentParams env={env} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel isActive={activeTab === 'template'}>
|
||||
<ConfigTemplateEditor config={config} onSaved={onTemplateSaved} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel isActive={activeTab === 'raw'}>
|
||||
<ContentRaw config={config} env={env} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel isActive={activeTab === 'test'}>
|
||||
<ContentTest config={config} env={env} />
|
||||
</TabPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentTabs(props: { onSelected: (id: ContentType) => void, selectedTab: ContentType, hasValidationWarnings: boolean }) {
|
||||
function clickHandler(type: ContentType) {
|
||||
props.onSelected(type);
|
||||
}
|
||||
|
||||
function isActive(type: ContentType): string {
|
||||
return type == props.selectedTab ? " active" : " ";
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="nav nav-pills nav-fill">
|
||||
<li className="nav-item">
|
||||
<a className={"nav-link" + isActive(ContentType.Env)} aria-current="page" href="#" onClick={() => clickHandler(ContentType.Env)}>Env</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className={"nav-link" + isActive(ContentType.Json)} href="#" onClick={() => clickHandler(ContentType.Json)} >
|
||||
Content Template
|
||||
{props.hasValidationWarnings && (
|
||||
<span className="badge bg-warning text-dark ms-1">!</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className={"nav-link" + isActive(ContentType.Raw)} href="#" onClick={() => clickHandler(ContentType.Raw)}>Raw template</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className={"nav-link" + isActive(ContentType.Test)} href="#" onClick={() => clickHandler(ContentType.Test)}>Test-filled template</a>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
function ContentParams({ env }: { env: Env }) {
|
||||
const xml = Builder.getEnv(env).build();
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<CodeBlock code={xml} language="xml" maxHeight="500px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentRaw(props: { config: Config, env: Env }) {
|
||||
const envsXml = Builder.getEnvs(props.config.envs);
|
||||
const templateContent = props.config.template.content;
|
||||
function ContentRaw({ config }: { config: Config; env: Env }) {
|
||||
const envsXml = Builder.getEnvs(config.envs);
|
||||
const templateContent = config.template.content;
|
||||
|
||||
const xml = `<engine>
|
||||
const xml = `<engine>
|
||||
${envsXml}
|
||||
<template>
|
||||
${templateContent}
|
||||
</template>
|
||||
</engine>`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Highlight className="language-xml">
|
||||
{xml}
|
||||
</Highlight>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<CodeBlock code={xml} language="xml" maxHeight="500px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentTest(props: { config: Config, env: Env }) {
|
||||
const [selectedEnvId, setSelectedEnvId] = useState(props.env.id);
|
||||
const selectedEnv = props.config.envs.find(e => e.id === selectedEnvId) ?? props.env;
|
||||
function ContentTest({ config, env }: { config: Config; env: Env }) {
|
||||
const [selectedEnvId, setSelectedEnvId] = useState(env.id ?? 0);
|
||||
const selectedEnv = config.envs.find(e => e.id === selectedEnvId) ?? env;
|
||||
|
||||
const filledTemplate = fillTemplate(props.config, selectedEnv);
|
||||
const filledTemplate = fillTemplate(config, selectedEnv);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Select Environment:</label>
|
||||
<select
|
||||
className="form-select w-auto d-inline-block"
|
||||
value={selectedEnvId}
|
||||
onChange={(e) => setSelectedEnvId(Number(e.target.value))}
|
||||
>
|
||||
{props.config.envs.map(env => (
|
||||
<option key={env.id} value={env.id}>{env.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Highlight className="language-json">
|
||||
{filledTemplate}
|
||||
</Highlight>
|
||||
</>
|
||||
)
|
||||
const selectOptions = config.envs.map((e) => ({
|
||||
value: e.id ?? 0,
|
||||
label: e.name ?? 'Unknown',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-slate-700">Select Environment:</label>
|
||||
<select
|
||||
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={selectedEnvId}
|
||||
onChange={(e) => setSelectedEnvId(Number(e.target.value))}
|
||||
>
|
||||
{selectOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<CodeBlock code={filledTemplate} language="json" maxHeight="500px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fillTemplate(config: Config, env: Env): string {
|
||||
const defaultEnv = config.envs.find(e => e.name === "DEFAULT");
|
||||
const paramMap = new Map<string, string>();
|
||||
const defaultEnv = config.envs.find((e) => e.name === 'DEFAULT');
|
||||
const paramMap = new Map<string, string>();
|
||||
|
||||
// First, load DEFAULT values as fallback
|
||||
if (defaultEnv) {
|
||||
for (const param of defaultEnv.params) {
|
||||
if (param.name && param.value !== undefined) {
|
||||
paramMap.set(param.name, param.value);
|
||||
}
|
||||
}
|
||||
// Load DEFAULT values first
|
||||
if (defaultEnv) {
|
||||
for (const param of defaultEnv.params) {
|
||||
if (param.name && param.value !== undefined) {
|
||||
paramMap.set(param.name, param.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then, override with selected environment values (precedence)
|
||||
for (const param of env.params) {
|
||||
if (param.name && param.value !== undefined) {
|
||||
paramMap.set(param.name, param.value);
|
||||
}
|
||||
// Override with selected environment values
|
||||
for (const param of env.params) {
|
||||
if (param.name && param.value !== undefined) {
|
||||
paramMap.set(param.name, param.value);
|
||||
}
|
||||
}
|
||||
|
||||
let filledTemplate = config.template.content;
|
||||
const placeholderRegex = /@(\w+)@/g;
|
||||
let filledTemplate = config.template.content;
|
||||
const placeholderRegex = /@(\w+)@/g;
|
||||
|
||||
filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => {
|
||||
if (paramName === Config.ENV_NAME_PARAM) {
|
||||
return env.name ?? "--NO-VALUE--";
|
||||
}
|
||||
return paramMap.get(paramName) ?? "--NO-VALUE--";
|
||||
});
|
||||
filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => {
|
||||
if (paramName === Config.ENV_NAME_PARAM) {
|
||||
return env.name ?? '--NO-VALUE--';
|
||||
}
|
||||
return paramMap.get(paramName) ?? '--NO-VALUE--';
|
||||
});
|
||||
|
||||
return filledTemplate;
|
||||
return filledTemplate;
|
||||
}
|
||||
|
||||
function ContentParams(props: { env: Env }) {
|
||||
const bldr = Builder.getEnv(props.env);
|
||||
|
||||
return (
|
||||
<Highlight className="language-xml">
|
||||
{bldr.build()}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
270
src/componets/env/Environment.tsx
vendored
270
src/componets/env/Environment.tsx
vendored
@@ -1,125 +1,153 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { AddEvent, AppEvent, DelEvent, Env, UpdateEvent } from "../../models/Env";
|
||||
import { EnvParam } from "../../models/EnvParam";
|
||||
import { EnvironmentParam } from "./EnvironmentParam";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Minus } from 'lucide-react';
|
||||
import { Button, Select, Card, CardBody } from '../../components/ui';
|
||||
import { Env, AddEvent, RemoveEvent, UpdateEvent } from '../../models/Env';
|
||||
import { EnvParam } from '../../models/EnvParam';
|
||||
import { EnvironmentParam } from './EnvironmentParam';
|
||||
|
||||
export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, onSelected: (envId: number) => void, onAdd: (env: Env) => number, onRemove: (envId: number) => void }) {
|
||||
const [currEnvId, setCurrEnvId] = useState(props.envs[0]?.id);
|
||||
|
||||
// Sync currEnvId when props.envs changes
|
||||
useEffect(() => {
|
||||
if (!props.envs.find(e => e.id === currEnvId)) {
|
||||
setCurrEnvId(props.envs[0]?.id);
|
||||
}
|
||||
}, [props.envs, currEnvId]);
|
||||
|
||||
const currEnv = props.envs.find(e => e.id === currEnvId) ?? props.envs[0];
|
||||
|
||||
function handleParamChanged(e: AppEvent<EnvParam>) {
|
||||
let isChanged = false;
|
||||
let env = currEnv;
|
||||
|
||||
if (e instanceof DelEvent) {
|
||||
env = currEnv.delParam(e.payload);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (e instanceof AddEvent) {
|
||||
env = currEnv.addParams(e.payload);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (e instanceof UpdateEvent) {
|
||||
env = currEnv.updateParams(e.payload);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (isChanged) {
|
||||
props.onChanged(env);
|
||||
setCurrEnvId(env.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddEnv() {
|
||||
const name = prompt("Enter new environment name:");
|
||||
if (!name || name.trim() === "") return;
|
||||
|
||||
// Calculate next integer ID based on max existing ID
|
||||
const maxId = props.envs.reduce((max, e) => Math.max(max, e.id ?? 0), -1);
|
||||
const newId = maxId + 1;
|
||||
|
||||
const newEnv = new Env(
|
||||
newId,
|
||||
name.trim(),
|
||||
[...currEnv.params]
|
||||
);
|
||||
// Parent synchronously adds the env and returns the index
|
||||
const newIdx = props.onAdd(newEnv);
|
||||
setCurrEnvId(newEnv.id);
|
||||
props.onSelected(newIdx);
|
||||
}
|
||||
|
||||
function handleRemoveEnv() {
|
||||
if (currEnv.isDefault()) {
|
||||
alert("Cannot remove DEFAULT environment");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Remove environment "${currEnv.name}"?`)) return;
|
||||
|
||||
const idx = props.envs.findIndex(x => x.id === currEnv.id);
|
||||
if (idx > -1 && currEnv.id !== undefined) {
|
||||
// Let parent handle the removal
|
||||
props.onRemove(currEnv.id);
|
||||
const newIdx = Math.max(0, idx - 1);
|
||||
const newEnv = props.envs[newIdx];
|
||||
if (newEnv?.id !== undefined) {
|
||||
setCurrEnvId(newEnv.id);
|
||||
}
|
||||
props.onSelected(newIdx);
|
||||
}
|
||||
}
|
||||
|
||||
const selectOptions = props.envs.map((x) => <option key={x.id} value={x.id} >{x.name}</option>);
|
||||
const paramCtrls = currEnv.params.map(x =>
|
||||
<EnvironmentParam key={`${currEnv.id}-${x.id}`}
|
||||
param={new EnvParam(x.id, x.name, x.value)}
|
||||
onChanged={handleParamChanged}
|
||||
isNew={false} />);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row g-0">
|
||||
<div className="col">
|
||||
<select
|
||||
id="environments"
|
||||
name="environments"
|
||||
aria-label="Environments"
|
||||
className="form-select"
|
||||
value={currEnvId}
|
||||
onChange={x => {
|
||||
let id = Number.parseInt(x.target.value);
|
||||
setCurrEnvId(id);
|
||||
props.onSelected(id);
|
||||
}}>
|
||||
{selectOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto ms-2">
|
||||
<button className="btn btn-success" onClick={handleAddEnv} title="Add environment">✚</button>
|
||||
</div>
|
||||
<div className="col-auto ms-2">
|
||||
<button className="btn btn-danger" onClick={handleRemoveEnv} title="Remove environment" disabled={currEnv.isDefault()}>−</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">Params</div>
|
||||
{paramCtrls}
|
||||
<EnvironmentParam key={`${currEnv.id}-new`}
|
||||
param={new EnvParam(-1, "", "")}
|
||||
onChanged={handleParamChanged}
|
||||
isNew={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
interface EnvironmentProps {
|
||||
envs: Env[];
|
||||
onChanged: (env: Env) => void;
|
||||
onSelected: (envId: number) => void;
|
||||
onAdd: (env: Env) => number;
|
||||
onRemove: (envId: number) => void;
|
||||
}
|
||||
|
||||
export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: EnvironmentProps) {
|
||||
const [currEnvId, setCurrEnvId] = useState<number>(envs[0]?.id ?? 0);
|
||||
|
||||
// Sync currEnvId when envs changes
|
||||
useEffect(() => {
|
||||
if (!envs.find(e => e.id === currEnvId)) {
|
||||
setCurrEnvId(envs[0]?.id ?? 0);
|
||||
}
|
||||
}, [envs, currEnvId]);
|
||||
|
||||
const currEnv = envs.find(e => e.id === currEnvId) ?? envs[0];
|
||||
|
||||
function handleParamChanged(event: AddEvent<EnvParam> | RemoveEvent<EnvParam> | UpdateEvent<EnvParam>) {
|
||||
let newEnv: Env = currEnv;
|
||||
let isChanged = false;
|
||||
|
||||
if (event instanceof RemoveEvent) {
|
||||
newEnv = currEnv.delParam(event.payload);
|
||||
isChanged = true;
|
||||
} else if (event instanceof AddEvent) {
|
||||
newEnv = currEnv.addParams(event.payload);
|
||||
isChanged = true;
|
||||
} else if (event instanceof UpdateEvent) {
|
||||
newEnv = currEnv.updateParams(event.payload);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (isChanged) {
|
||||
onChanged(newEnv);
|
||||
setCurrEnvId(newEnv.id ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddEnv() {
|
||||
const name = prompt('Enter new environment name:');
|
||||
if (!name || name.trim() === '') return;
|
||||
|
||||
// Calculate next integer ID based on max existing ID
|
||||
const maxId = envs.reduce((max, e) => Math.max(max, e.id ?? 0), -1);
|
||||
const newId = maxId + 1;
|
||||
|
||||
const newEnv = new Env(newId, name.trim(), [...currEnv.params]);
|
||||
const newIdx = onAdd(newEnv);
|
||||
setCurrEnvId(newEnv.id ?? 0);
|
||||
onSelected(newIdx);
|
||||
}
|
||||
|
||||
function handleRemoveEnv() {
|
||||
if (currEnv.isDefault()) {
|
||||
alert('Cannot remove DEFAULT environment');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Remove environment "${currEnv.name}"?`)) return;
|
||||
|
||||
const idx = envs.findIndex(x => x.id === currEnv.id);
|
||||
if (idx > -1 && currEnv.id !== undefined) {
|
||||
onRemove(currEnv.id);
|
||||
const newIdx = Math.max(0, idx - 1);
|
||||
const newEnv = envs[newIdx];
|
||||
if (newEnv?.id !== undefined) {
|
||||
setCurrEnvId(newEnv.id);
|
||||
}
|
||||
onSelected(newIdx);
|
||||
}
|
||||
}
|
||||
|
||||
const selectOptions = envs.map((x) => ({
|
||||
value: x.id ?? 0,
|
||||
label: x.name ?? 'Unknown',
|
||||
}));
|
||||
|
||||
const paramCtrls = currEnv.params.map((x) => (
|
||||
<EnvironmentParam
|
||||
key={`${currEnv.id}-${x.id}`}
|
||||
param={new EnvParam(x.id, x.name, x.value)}
|
||||
onChanged={handleParamChanged}
|
||||
isNew={false}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Card variant="bordered" padding="none" className="h-full">
|
||||
<CardBody className="space-y-4">
|
||||
{/* Environment Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="Environment"
|
||||
value={currEnvId}
|
||||
options={selectOptions}
|
||||
onChange={(e) => {
|
||||
const id = Number.parseInt(e.target.value);
|
||||
setCurrEnvId(id);
|
||||
onSelected(id);
|
||||
}}
|
||||
id="environments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={handleAddEnv}
|
||||
title="Add environment"
|
||||
icon={Plus}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleRemoveEnv}
|
||||
title="Remove environment"
|
||||
icon={Minus}
|
||||
disabled={currEnv.isDefault()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parameters Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3 uppercase tracking-wide">
|
||||
Parameters
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paramCtrls}
|
||||
|
||||
<EnvironmentParam
|
||||
key={`${currEnv.id}-new`}
|
||||
param={new EnvParam(-1, '', '')}
|
||||
onChanged={handleParamChanged}
|
||||
isNew={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
168
src/componets/env/EnvironmentParam.tsx
vendored
168
src/componets/env/EnvironmentParam.tsx
vendored
@@ -1,68 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { EnvParam } from "../../models/EnvParam";
|
||||
import { AppEvent } from "../../models/Env";
|
||||
import { useState } from 'react';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { Button, Input } from '../../components/ui';
|
||||
import { EnvParam } from '../../models/EnvParam';
|
||||
import { AddEvent, RemoveEvent, UpdateEvent } from '../../models/Env';
|
||||
|
||||
|
||||
export function EnvironmentParam(props: { param: EnvParam; onChanged: (e: AppEvent<EnvParam>) => void, isNew: boolean }) {
|
||||
const [param, setParam] = useState(props.param);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
function doSet(x: string, act: (x: string) => void) {
|
||||
act(x);
|
||||
setParam(param.Changed(true));
|
||||
}
|
||||
|
||||
function handleChange() {
|
||||
if (!param.isChanged)
|
||||
return;
|
||||
|
||||
let newParam = param.Changed(false);
|
||||
if (!props.isNew) {
|
||||
props.onChanged(AppEvent.update(newParam));
|
||||
}
|
||||
|
||||
setParam(newParam);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
props.onChanged(AppEvent.add(param));
|
||||
setParam(new EnvParam(0, "", ""));
|
||||
}
|
||||
|
||||
function handleKeyUp(x: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (x.key === "Enter") { handleChange(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"row px-0" + (param.isChanged ? "border border-warning" : "")}
|
||||
style={isFocused ? { backgroundColor: "lightskyblue", padding: "1px 0" } : { padding: "1px 0" }}>
|
||||
<div className="col-4 mx-0 px-0">
|
||||
<input type="text"
|
||||
className="form-control"
|
||||
style={{ backgroundColor: "rgba(170, 170, 247, 0.16)" }}
|
||||
value={param.name}
|
||||
onChange={x => doSet(x.target.value, (v) => param.name = v)}
|
||||
onBlur={() => { handleChange(); setIsFocused(false); }}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder="name"
|
||||
aria-label="name" />
|
||||
</div>
|
||||
<div className="col mx-0 px-0">
|
||||
<input type="text"
|
||||
className="form-control"
|
||||
value={param.value}
|
||||
onChange={x => doSet(x.target.value, v => param.value = v)}
|
||||
onBlur={() => { handleChange(); setIsFocused(false); }}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder="value"
|
||||
aria-label="value" />
|
||||
</div>
|
||||
<div className="col-1 mx-0 px-0" >
|
||||
<button className="btn btn-success" hidden={!props.isNew} onClick={handleAdd}>✓</button>
|
||||
<button className="btn btn-warning" hidden={props.isNew} onClick={() => props.onChanged(AppEvent.del(param))} tabIndex={-1}>−</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
interface EnvironmentParamProps {
|
||||
param: EnvParam;
|
||||
onChanged: (event: AddEvent<EnvParam> | RemoveEvent<EnvParam> | UpdateEvent<EnvParam>) => void;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamProps) {
|
||||
const [localParam, setLocalParam] = useState(param);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
function updateParam(updates: Partial<EnvParam>) {
|
||||
const updated = localParam.update(updates).markChanged(true);
|
||||
setLocalParam(updated);
|
||||
}
|
||||
|
||||
function handleChange() {
|
||||
if (!localParam.isChanged) return;
|
||||
|
||||
const savedParam = localParam.markChanged(false);
|
||||
|
||||
if (!isNew) {
|
||||
onChanged(UpdateEvent.update(savedParam));
|
||||
}
|
||||
|
||||
setLocalParam(savedParam);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
onChanged(AddEvent.add(localParam));
|
||||
setLocalParam(new EnvParam(0, '', ''));
|
||||
}
|
||||
|
||||
function handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
handleChange();
|
||||
}
|
||||
}
|
||||
|
||||
const isChangedClass = localParam.isChanged ? 'ring-2 ring-yellow-400 border-yellow-400' : '';
|
||||
const focusedClass = isFocused ? 'bg-blue-50' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
grid grid-cols-12 gap-2 p-2 rounded-lg transition-all duration-200
|
||||
${isChangedClass}
|
||||
${focusedClass ? 'bg-blue-50' : 'bg-white'}
|
||||
hover:bg-slate-50
|
||||
`}
|
||||
>
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
value={localParam.name ?? ''}
|
||||
onChange={(e) => updateParam({ name: e.target.value })}
|
||||
onBlur={() => { handleChange(); setIsFocused(false); }}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder="Parameter name"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-7">
|
||||
<Input
|
||||
value={localParam.value ?? ''}
|
||||
onChange={(e) => updateParam({ value: e.target.value })}
|
||||
onBlur={() => { handleChange(); setIsFocused(false); }}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder="Parameter value"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
{isNew ? (
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
title="Add parameter"
|
||||
icon={Check}
|
||||
className="px-2"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onChanged(new RemoveEvent(localParam))}
|
||||
title="Remove parameter"
|
||||
icon={Minus}
|
||||
className="px-2 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user