24 Commits

Author SHA1 Message Date
ssa
f7287186f2 Merge pull request 'feat' (#10) from feat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Deploy to Server / deploy (push) Has been cancelled
Build Docker Image / build (push) Has been cancelled
Reviewed-on: #10
2026-02-20 17:33:44 +03:00
sokol
ecded86467 feat: show validation warning next to View Mode / Valid JSON badge
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
2026-02-20 17:28:37 +03:00
sokol
70e0545fef feat: show validation warning description inline 2026-02-20 17:17:11 +03:00
sokol
1beb9f2026 fix: preserve existing template when adding parameters 2026-02-20 17:09:34 +03:00
sokol
28fc3f9e21 fix: improved JSON validation for comments and placeholders 2026-02-20 16:55:34 +03:00
ssa
d87d86bacb Merge pull request 'feat: JSON comments support + full-height layout' (#9) from feat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Deploy to Server / deploy (push) Has been cancelled
Build Docker Image / build (push) Has been cancelled
Reviewed-on: #9
2026-02-20 16:42:18 +03:00
sokol
9f51379df9 feat: JSON comments support + full-height layout
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
2026-02-20 16:40:13 +03:00
ssa
75ed50961f Merge pull request 'feat' (#8) from feat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Deploy to Server / deploy (push) Has been cancelled
Build Docker Image / build (push) Has been cancelled
Reviewed-on: #8
2026-02-20 16:29:37 +03:00
sokol
c8f45e4962 style: set environment panel to 5/12 width
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Successful in 45m28s
2026-02-20 16:27:34 +03:00
sokol
d89ce6ebbd style: maximize content width on large displays 2026-02-20 16:26:08 +03:00
sokol
8cc66ea9d6 style: adjust layout proportions 2026-02-20 16:10:38 +03:00
sokol
6e5a453c6f style: add gap-1 and p-1 to parameter rows 2026-02-20 16:02:43 +03:00
ssa
76b54cff3b Merge pull request 'feat' (#7) from feat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Deploy to Server / deploy (push) Has been cancelled
Build Docker Image / build (push) Has been cancelled
Reviewed-on: #7
2026-02-20 15:45:41 +03:00
sokol
ead551ee6b style: ultra-compact Environment section
All checks were successful
CI / build-and-test (push) Successful in 29m26s
CI / build-and-test (pull_request) Successful in 26m23s
2026-02-20 14:36:02 +03:00
sokol
25f81cb8f7 style: make Environment section much more compact 2026-02-20 14:34:24 +03:00
sokol
482788a1d1 style: make Environment section more compact
All checks were successful
CI / build-and-test (push) Successful in 29m11s
2026-02-20 14:32:12 +03:00
ssa
31d9e388f8 Merge pull request 'feat' (#6) from feat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Build Docker Image / build (push) Has been cancelled
Deploy to Server / deploy (push) Failing after 17m35s
Reviewed-on: #6
2026-02-20 13:58:50 +03:00
sokol
9e313f5b86 feat: native deploy workflow without SSH
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
2026-02-20 13:55:53 +03:00
sokol
c735d7318f ci: fix E2E tests by starting dev server
All checks were successful
CI / build-and-test (push) Successful in 30m18s
2026-02-20 12:35:51 +03:00
sokol
9c94aa1df5 test: fix E2E download test for empty config protection
Some checks failed
CI / build-and-test (push) Failing after 24m44s
2026-02-20 11:57:56 +03:00
sokol
e4b44c7b5e refactor: improve type safety and code style
Some checks failed
CI / build-and-test (push) Failing after 24m31s
2026-02-20 11:22:56 +03:00
sokol
1c27b68965 feat: build Docker image locally without pushing to registry
Some checks failed
CI / build-and-test (push) Failing after 12m44s
2026-02-20 10:33:17 +03:00
sokol
083d7dd662 feat: add Gitea Actions CI/CD workflows
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-20 10:29:22 +03:00
ssa
bc796f2aa4 Merge pull request 'rewrite' (#5) from rewrite into main
Reviewed-on: #5
2026-02-19 23:48:41 +03:00
20 changed files with 843 additions and 87 deletions

View File

@@ -0,0 +1,99 @@
# Gitea Actions Workflows
## Workflows
### 1. CI (`ci.yml`)
**Triggers:** Push/PR to `main`, `rewrite`, `feat` branches
**Jobs:**
- Install dependencies
- Lint code
- Build application
- Run unit tests (Vitest)
- Run E2E tests (Playwright)
- Upload test results as artifacts
### 2. Docker Build (`docker-build.yml`)
**Triggers:**
- Tags matching `v*` (e.g., `v1.0.0`)
- Push to `main` branch
**Jobs:**
- Build React application
- Build Docker image locally
- Save image as tarball artifact
- Upload artifact for download (30 days retention)
**Output:**
- Docker image artifact: `configucci.tar`
- Can be downloaded and loaded with: `docker load -i configucci.tar`
### 3. Deploy (`deploy.yml`)
**Triggers:** Push to `main` branch
**Jobs:**
- Build React application
- Build Docker image locally
- Create docker-compose.yml configuration
- Deploy container on Gitea runner (port 11088)
- Health check to verify application is running
- Cleanup old Docker images
**No SSH required** - Everything runs natively on the Gitea Actions runner!
**Output:**
- Application available at: `http://<gitea-server>:11088`
- Container auto-restarts on failure
- Health check ensures successful deployment
## Setup Instructions
### 1. Enable Gitea Actions
Make sure Actions is enabled in your Gitea instance:
```ini
[actions]
ENABLED = true
```
### 2. Configure Runner
Ensure your Gitea runner has Docker and docker-compose installed:
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install docker-compose
sudo apt-get install docker-compose-plugin
```
**No secrets required** - Everything runs on the runner!
## Workflow Files Location
`.gitea/workflows/`
## Testing Workflows Locally
You can test workflows locally using [act](https://github.com/nektos/act):
```bash
# Install act
brew install act
# Run CI workflow locally
act push
# Run with specific job
act -j build-and-test
# Run with verbose output
act -v
```
## Badge Examples
Add these to your README.md:
```markdown
[![CI](https://git.six83.ru/ssa/configucci/actions/workflows/ci.yml/badge.svg)](https://git.six83.ru/ssa/configucci/actions/workflows/ci.yml)
[![Deploy](https://git.six83.ru/ssa/configucci/actions/workflows/deploy.yml/badge.svg)](https://git.six83.ru/ssa/configucci/actions/workflows/deploy.yml)
```

65
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main, rewrite, feat]
pull_request:
branches: [main, rewrite, feat]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Run unit tests
run: npm run test
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start dev server
run: npm run dev &
env:
HOST: 0.0.0.0
PORT: 5173
- name: Wait for dev server
run: |
echo "Waiting for dev server to start..."
for i in {1..30}; do
if curl -s http://localhost:5173 > /dev/null 2>&1; then
echo "Dev server is ready!"
exit 0
fi
sleep 1
done
echo "Dev server failed to start"
exit 1
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/

View File

@@ -0,0 +1,76 @@
name: Deploy to Server
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: configucci:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create docker-compose.yml
run: |
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
configucci:
image: configucci:latest
container_name: configucci
ports:
- "11088:80"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
EOF
- name: Stop existing containers
run: docker-compose down || true
- name: Start new container
run: docker-compose up -d
- name: Wait for application health
run: |
echo "Waiting for application to be healthy..."
for i in {1..30}; do
if curl -s http://localhost:11088 > /dev/null 2>&1; then
echo "Application is ready!"
exit 0
fi
sleep 2
done
echo "Application failed to start"
exit 1
- name: Cleanup old images
run: docker system prune -f

View File

@@ -0,0 +1,51 @@
name: Build Docker Image
on:
push:
tags:
- 'v*'
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: configucci:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Save Docker image as tarball
run: docker save configucci:latest -o configucci.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: configucci.tar
retention-days: 30

View File

@@ -179,6 +179,19 @@ test.describe('Environment Management', () => {
await page.goto('/'); await page.goto('/');
await page.click('button:has-text("New Config")'); await page.click('button:has-text("New Config")');
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Add a parameter to make the config non-empty (Download button requires non-empty config)
await page.click('button[title="Add parameter"]');
await page.waitForTimeout(300);
const nameInput = page.locator('input[placeholder="Parameter name"]').last();
const valueInput = page.locator('input[placeholder="Parameter value"]').last();
const addButton = page.locator('button[title="Add parameter"]').last();
await nameInput.fill('host');
await valueInput.fill('localhost');
await addButton.click();
await page.waitForTimeout(500);
// Now download should work
const [download] = await Promise.all([ const [download] = await Promise.all([
page.waitForEvent('download'), page.waitForEvent('download'),
page.click('button:has-text("Download")') page.click('button:has-text("Download")')

View File

@@ -3,21 +3,31 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default tseslint.config(
globalIgnores(['dist']), {
ignores: ['dist'],
},
{ {
files: ['**/*.{ts,tsx}'],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
], ],
files: ['**/*.{ts,tsx}'],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}, },
]) )

View File

@@ -98,7 +98,7 @@ function App() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100"> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<main className="container mx-auto px-4 py-6 max-w-7xl"> <main className="container mx-auto px-4 py-6 max-w-[1920px] min-h-[calc(100vh-48px)]">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<FileChooser <FileChooser
@@ -112,10 +112,10 @@ function App() {
</div> </div>
{envs.length > 0 ? ( {envs.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-[calc(100vh-200px)]">
{/* Environment Panel */} {/* Environment Panel - 5/12 width */}
<section className="lg:col-span-5 xl:col-span-4"> <section className="lg:col-span-5 xl:col-span-5 2xl:col-span-5">
<div className="sticky top-6"> <div className="sticky top-6 h-full">
<Environment <Environment
envs={envs} envs={envs}
onChanged={async (e) => await handleEnvChanged(e)} onChanged={async (e) => await handleEnvChanged(e)}
@@ -126,8 +126,8 @@ function App() {
</div> </div>
</section> </section>
{/* Content Panel */} {/* Content Panel - 7/12 width */}
<section className="lg:col-span-7 xl:col-span-8"> <section className="lg:col-span-7 xl:col-span-7 2xl:col-span-7 h-full">
<Content <Content
env={currentEnv} env={currentEnv}
config={config} config={config}

View File

@@ -34,7 +34,7 @@ export class EnvBuilder implements IBuilder<Env> {
private params(): this { private params(): this {
const tag = `<parameter name="{name}" value="{val}" />`; const tag = `<parameter name="{name}" value="{val}" />`;
for (let p of this.src.params) { for (const p of this.src.params) {
this.stack.push(this.ident); this.stack.push(this.ident);
this.stack.push(tag this.stack.push(tag
.replace("{name}", p.name ?? "!ERR!") .replace("{name}", p.name ?? "!ERR!")

View File

@@ -11,7 +11,7 @@ export interface IBuilder<T> {
export class Builder { export class Builder {
public static getEnv(env: Env): IBuilder<Env> { public static getEnv(env: Env): IBuilder<Env> {
let b = new EnvBuilder(); const b = new EnvBuilder();
b.src = env; b.src = env;
return b; return b;
}; };

View File

@@ -8,7 +8,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
icon?: LucideIcon; icon?: LucideIcon;
iconPosition?: 'left' | 'right';
isLoading?: boolean; isLoading?: boolean;
} }
@@ -33,7 +32,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
icon: Icon, icon: Icon,
iconPosition = 'left',
isLoading = false, isLoading = false,
disabled, disabled,
children, children,

View File

@@ -34,7 +34,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
Card.displayName = 'Card'; Card.displayName = 'Card';
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {} type CardHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>( export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className = '', children, ...props }, ref) => { ({ className = '', children, ...props }, ref) => {
@@ -52,7 +52,7 @@ export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
CardHeader.displayName = 'CardHeader'; CardHeader.displayName = 'CardHeader';
interface CardBodyProps extends HTMLAttributes<HTMLDivElement> {} type CardBodyProps = HTMLAttributes<HTMLDivElement>;
export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>( export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
({ className = '', children, ...props }, ref) => { ({ className = '', children, ...props }, ref) => {
@@ -66,7 +66,7 @@ export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
CardBody.displayName = 'CardBody'; CardBody.displayName = 'CardBody';
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {} type CardFooterProps = HTMLAttributes<HTMLDivElement>;
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>( export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className = '', children, ...props }, ref) => { ({ className = '', children, ...props }, ref) => {

View File

@@ -14,6 +14,13 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
const [originalContent, setOriginalContent] = useState(config.template.content); const [originalContent, setOriginalContent] = useState(config.template.content);
const [jsonError, setJsonError] = useState<string | null>(null); const [jsonError, setJsonError] = useState<string | null>(null);
// Validate placeholders for warning
const missingPlaceholders = config.validatePlaceholders();
const hasValidationWarnings = missingPlaceholders.length > 0;
const warningMessage = hasValidationWarnings
? `Missing params: ${missingPlaceholders.join(', ')}`
: '';
// Sync draft when config changes (only in view mode) // Sync draft when config changes (only in view mode)
useEffect(() => { useEffect(() => {
if (mode === 'view') { if (mode === 'view') {
@@ -34,33 +41,82 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
setMode('view'); setMode('view');
} }
function handleSave() { function validateJson(value: string): boolean {
// Validate JSON before saving (with placeholder support)
try { try {
const sanitizedValue = draftContent.replace(/@[^@]+@/g, '1'); if (!value.trim()) {
JSON.parse(sanitizedValue); setJsonError(null);
return true;
}
// Step 1: Remove /* */ multi-line comments (safe, can't appear in strings)
let sanitized = value.replace(/\/\*[\s\S]*?\*\//g, '');
// Step 2: Process line by line to handle // comments and placeholders
// Only remove // comments that are OUTSIDE of quoted strings
const lines = sanitized.split('\n');
const processedLines = lines.map(line => {
// Find // that's outside quotes
// Strategy: split by quotes, only process odd-indexed segments (outside quotes)
let result = '';
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < line.length; i++) {
const char = line[i];
const prevChar = i > 0 ? line[i - 1] : '';
// Check for quote start/end (not escaped)
if ((char === '"' || char === "'") && prevChar !== '\\') {
if (!inQuote) {
inQuote = true;
quoteChar = char;
} else if (char === quoteChar) {
inQuote = false;
quoteChar = '';
}
result += char;
}
// Check for // comment start (only outside quotes)
else if (!inQuote && char === '/' && line[i + 1] === '/') {
// Rest of line is comment, stop processing
break;
}
else {
result += char;
}
}
return result;
});
sanitized = processedLines.join('\n');
// Step 3: Replace unquoted @placeholders@ with dummy values
// Placeholders in JSON values are typically quoted: "@param@"
// We replace all @param@ with 1 (works for both quoted and unquoted)
sanitized = sanitized.replace(/@[^@]+@/g, '1');
JSON.parse(sanitized);
setJsonError(null); setJsonError(null);
onSaved(draftContent); return true;
setMode('view');
} catch (e) { } catch (e) {
setJsonError((e as Error).message); setJsonError((e as Error).message);
return false;
}
}
function handleSave() {
// Validate JSON before saving (with comment and placeholder support)
if (validateJson(draftContent)) {
onSaved(draftContent);
setMode('view');
} }
} }
function handleDraftChange(value: string) { function handleDraftChange(value: string) {
setDraftContent(value); setDraftContent(value);
// Validate JSON on every change // Validate JSON on every change
try { validateJson(value);
if (value.trim()) {
const sanitizedValue = value.replace(/@[^@]+@/g, '1');
JSON.parse(sanitizedValue);
setJsonError(null);
} else {
setJsonError(null);
}
} catch (e) {
setJsonError((e as Error).message);
}
} }
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
@@ -88,9 +144,15 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
<div className="config-template-editor animate-fade-in"> <div className="config-template-editor animate-fade-in">
{mode === 'view' ? ( {mode === 'view' ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<Badge variant="success">View Mode</Badge> <Badge variant="success">View Mode</Badge>
{hasValidationWarnings && (
<div className="flex items-center gap-1.5 text-sm text-amber-600 bg-amber-50 px-2.5 py-1 rounded-md">
<span className="font-medium">Warning:</span>
<span>{warningMessage}</span>
</div>
)}
</div> </div>
<Button <Button
variant="primary" variant="primary"
@@ -101,7 +163,7 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
Edit Template Edit Template
</Button> </Button>
</div> </div>
<CodeBlock code={config.template.content || '{}'} language="json" maxHeight="500px" /> <CodeBlock code={config.template.content || '{}'} language="json" maxHeight="500px" />
</div> </div>
) : ( ) : (
@@ -116,7 +178,7 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
> >
Save Changes Save Changes
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -139,6 +201,13 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
</> </>
)} )}
</Badge> </Badge>
{hasValidationWarnings && isValidJson && (
<div className="flex items-center gap-1.5 text-sm text-amber-600 bg-amber-50 px-2.5 py-1 rounded-md">
<span className="font-medium">Warning:</span>
<span>{warningMessage}</span>
</div>
)}
</div> </div>
{jsonError && ( {jsonError && (

View File

@@ -20,8 +20,8 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) {
const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [ const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [
{ id: 'env', label: 'Env' }, { id: 'env', label: 'Env' },
{ {
id: 'template', id: 'template',
label: 'Content Template', label: 'Content Template',
badge: hasValidationWarnings ? '!' : undefined, badge: hasValidationWarnings ? '!' : undefined,
badgeVariant: hasValidationWarnings ? 'warning' : undefined, badgeVariant: hasValidationWarnings ? 'warning' : undefined,
@@ -31,22 +31,24 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) {
]; ];
return ( return (
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden h-full flex flex-col">
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} /> <div className="flex-shrink-0">
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
<div className="p-4"> </div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<TabPanel isActive={activeTab === 'env'}> <TabPanel isActive={activeTab === 'env'}>
<ContentParams env={env} /> <ContentParams env={env} />
</TabPanel> </TabPanel>
<TabPanel isActive={activeTab === 'template'}> <TabPanel isActive={activeTab === 'template'}>
<ConfigTemplateEditor config={config} onSaved={onTemplateSaved} /> <ConfigTemplateEditor config={config} onSaved={onTemplateSaved} />
</TabPanel> </TabPanel>
<TabPanel isActive={activeTab === 'raw'}> <TabPanel isActive={activeTab === 'raw'}>
<ContentRaw config={config} env={env} /> <ContentRaw config={config} env={env} />
</TabPanel> </TabPanel>
<TabPanel isActive={activeTab === 'test'}> <TabPanel isActive={activeTab === 'test'}>
<ContentTest config={config} env={env} /> <ContentTest config={config} env={env} />
</TabPanel> </TabPanel>

View File

@@ -94,10 +94,10 @@ export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: En
)); ));
return ( return (
<Card variant="bordered" padding="none" className="h-full"> <Card variant="bordered" padding="none" className="h-full overflow-hidden flex flex-col">
<CardBody className="space-y-4"> <CardBody className="space-y-2 flex flex-col h-full overflow-hidden">
{/* Environment Selector */} {/* Environment Selector */}
<div className="flex gap-2"> <div className="flex-shrink-0 flex gap-2">
<div className="flex-1"> <div className="flex-1">
<Select <Select
label="Environment" label="Environment"
@@ -112,7 +112,7 @@ export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: En
/> />
</div> </div>
<div className="flex flex-col justify-center gap-2 pt-6"> <div className="flex flex-col justify-center gap-2 pt-6 flex-shrink-0">
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="success" variant="success"
@@ -134,15 +134,15 @@ export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: En
</div> </div>
</div> </div>
{/* Parameters Section */} {/* Parameters Section - Scrollable */}
<div> <div className="flex-1 overflow-hidden flex flex-col min-h-0">
<h3 className="text-sm font-semibold text-slate-700 mb-3 uppercase tracking-wide"> <h3 className="text-sm font-semibold text-slate-700 mb-1 uppercase tracking-wide flex-shrink-0">
Parameters Parameters
</h3> </h3>
<div className="space-y-2"> <div className="flex-1 overflow-y-auto space-y-0 pr-2 -mr-2">
{paramCtrls} {paramCtrls}
<EnvironmentParam <EnvironmentParam
key={`${currEnv.id}-new`} key={`${currEnv.id}-new`}
param={new EnvParam(-1, '', '')} param={new EnvParam(-1, '', '')}

View File

@@ -48,7 +48,7 @@ export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamPr
return ( return (
<div <div
className={` className={`
grid grid-cols-12 gap-2 p-2 rounded-lg transition-all duration-200 grid grid-cols-12 gap-1 p-1 rounded-lg transition-all duration-200
${isChangedClass} ${isChangedClass}
${focusedClass ? 'bg-blue-50' : 'bg-white'} ${focusedClass ? 'bg-blue-50' : 'bg-white'}
hover:bg-slate-50 hover:bg-slate-50
@@ -65,7 +65,7 @@ export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamPr
className="text-sm" className="text-sm"
/> />
</div> </div>
<div className="col-span-7"> <div className="col-span-7">
<Input <Input
value={localParam.value ?? ''} value={localParam.value ?? ''}
@@ -77,7 +77,7 @@ export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamPr
className="text-sm" className="text-sm"
/> />
</div> </div>
<div className="col-span-1 flex items-center justify-center"> <div className="col-span-1 flex items-center justify-center">
{isNew ? ( {isNew ? (
<Button <Button

View File

@@ -97,29 +97,48 @@ export class Config {
* Updates template by adding placeholders for environment params * Updates template by adding placeholders for environment params
*/ */
public updateTemplateFromEnv(env: Env): void { public updateTemplateFromEnv(env: Env): void {
let templateObj: Record<string, any> = {}; // If template is empty, initialize with empty object
if (!this.template.content || !this.template.content.trim()) {
this.template = new ConfigTemplate('{}');
}
// Try to parse existing template // Try to parse existing template
let templateObj: Record<string, any> = {};
let hasExistingContent = false;
try { try {
if (this.template.content.trim()) { if (this.template.content.trim()) {
templateObj = JSON.parse(this.template.content); templateObj = JSON.parse(this.template.content);
hasExistingContent = Object.keys(templateObj).length > 0;
} }
} catch { } catch {
// Start fresh if invalid JSON // If invalid JSON, preserve the raw content and don't modify
return;
} }
// Add placeholders for params that don't exist yet // Add placeholders for params that don't exist yet
let hasChanges = false;
for (const param of env.params) { for (const param of env.params) {
if (param.name && param.name.trim()) { if (param.name && param.name.trim()) {
const placeholder = `@${param.name}@`; const paramName = param.name.trim();
const placeholder = `@${paramName}@`;
const templateKey = `!!! ${paramName}`;
// Check if placeholder exists anywhere in template
if (!this.template.content.includes(placeholder)) { if (!this.template.content.includes(placeholder)) {
templateObj[`!!! ${param.name}`] = placeholder; // Only add if not already in templateObj
if (!templateObj[templateKey]) {
templateObj[templateKey] = placeholder;
hasChanges = true;
}
} }
} }
} }
this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4)); // Only update if there are actual changes
if (hasChanges || !hasExistingContent) {
this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4));
}
} }
/** /**

View File

@@ -36,8 +36,8 @@ const cfgTemplate = `
test("read from a file", async ({expect})=>{ test("read from a file", async ({expect})=>{
let sut = new ConfigReader(); const sut = new ConfigReader();
let file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'}); const file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'});
// define a missing jsdom text() function, // define a missing jsdom text() function,
// that presents in the real DOM // that presents in the real DOM
@@ -46,15 +46,15 @@ test("read from a file", async ({expect})=>{
writable: true writable: true
}); });
let cfg = await sut.parseFromFile(file); const cfg = await sut.parseFromFile(file);
expect(cfg).not.toBeUndefined(); expect(cfg).not.toBeUndefined();
}); });
test("load environments and params", ({expect})=>{ test("load environments and params", ({expect})=>{
let sut = new ConfigReader(); const sut = new ConfigReader();
let cfg = sut.parseFromString(cfgTemplate); const cfg = sut.parseFromString(cfgTemplate);
expect(cfg?.envs).toHaveLength(3); expect(cfg?.envs).toHaveLength(3);
expect(cfg?.envs.map(x=>x.name)) expect(cfg?.envs.map(x=>x.name))
@@ -64,9 +64,9 @@ test("load environments and params", ({expect})=>{
}); });
test("load template", ({expect})=>{ test("load template", ({expect})=>{
let sut = new ConfigReader(); const sut = new ConfigReader();
let cfg = sut.parseFromString(cfgTemplate); const cfg = sut.parseFromString(cfgTemplate);
expect(cfg?.template).toBeDefined(); expect(cfg?.template).toBeDefined();
expect(cfg?.template.content.length).toBeGreaterThan(20); expect(cfg?.template.content.length).toBeGreaterThan(20);

View File

@@ -0,0 +1,159 @@
import { describe, it, expect } from 'vitest';
import { Config } from '../models/Config';
import { Env } from '../models/Env';
import { EnvParam } from '../models/EnvParam';
describe('Config Template Update', () => {
it('should preserve existing template when adding new parameter', () => {
// Arrange: Create config with existing template
const config = new Config();
const existingTemplate = `{
"existingKey": "existingValue",
"anotherKey": 123
}`;
config.setTemplate(existingTemplate);
const env = new Env(0, 'DEFAULT', [
new EnvParam(1, 'host', 'localhost'),
new EnvParam(2, 'port', '8080')
]);
config.setEnvs([env]);
// Act: Update template from env (simulating adding parameters)
config.updateTemplateFromEnv(env);
// Assert: Template should have both existing content and new placeholders
const templateContent = config.template.content;
const templateObj = JSON.parse(templateContent);
expect(templateObj.existingKey).toBe('existingValue');
expect(templateObj.anotherKey).toBe(123);
expect(templateObj['!!! host']).toBe('@host@');
expect(templateObj['!!! port']).toBe('@port@');
});
it('should not duplicate placeholders that already exist', () => {
// Arrange: Create config with template that already has placeholder
const config = new Config();
const existingTemplate = `{
"host": "@host@",
"port": 8080
}`;
config.setTemplate(existingTemplate);
const env = new Env(0, 'DEFAULT', [
new EnvParam(1, 'host', 'localhost'), // Already in template
new EnvParam(2, 'port', '8080') // Already in template
]);
config.setEnvs([env]);
// Act: Update template
config.updateTemplateFromEnv(env);
// Assert: Should not add !!! entries for existing placeholders
const templateContent = config.template.content;
// Count occurrences of @host@
const hostCount = (templateContent.match(/@host@/g) || []).length;
expect(hostCount).toBe(1); // Only original, no duplicate
// Should preserve original structure
expect(templateContent).toContain('"host": "@host@"');
});
it('should initialize empty template', () => {
// Arrange: Create config with empty template
const config = new Config();
config.setTemplate('');
const env = new Env(0, 'DEFAULT', [
new EnvParam(1, 'host', 'localhost')
]);
config.setEnvs([env]);
// Act: Update template
config.updateTemplateFromEnv(env);
// Assert: Should create template with placeholder
const templateContent = config.template.content;
const templateObj = JSON.parse(templateContent);
expect(templateObj['!!! host']).toBe('@host@');
});
it('should not modify invalid JSON template', () => {
// Arrange: Create config with invalid JSON template
const config = new Config();
const invalidTemplate = `{
"key": "value" // missing comma
"another": "key"
}`;
config.setTemplate(invalidTemplate);
const originalTemplate = config.template.content;
const env = new Env(0, 'DEFAULT', [
new EnvParam(1, 'host', 'localhost')
]);
config.setEnvs([env]);
// Act: Update template (should not modify invalid JSON)
config.updateTemplateFromEnv(env);
// Assert: Template should remain unchanged
expect(config.template.content).toBe(originalTemplate);
});
it('should add multiple new placeholders to existing template', () => {
// Arrange
const config = new Config();
const existingTemplate = `{
"name": "myapp"
}`;
config.setTemplate(existingTemplate);
const env = new Env(0, 'DEFAULT', [
new EnvParam(1, 'host', 'localhost'),
new EnvParam(2, 'port', '8080'),
new EnvParam(3, 'user', 'admin')
]);
config.setEnvs([env]);
// Act
config.updateTemplateFromEnv(env);
// Assert
const templateObj = JSON.parse(config.template.content);
expect(templateObj.name).toBe('myapp');
expect(templateObj['!!! host']).toBe('@host@');
expect(templateObj['!!! port']).toBe('@port@');
expect(templateObj['!!! user']).toBe('@user@');
});
it('should only add placeholders for params not in template', () => {
// Arrange: Template has @host@ but not @port@
const config = new Config();
config.setTemplate(`{
"host": "@host@",
"name": "test"
}`);
const env = new Env(0, 'DEFAULT', [
new EnvParam(1, 'host', 'localhost'), // In template
new EnvParam(2, 'port', '8080') // Not in template
]);
config.setEnvs([env]);
// Act
config.updateTemplateFromEnv(env);
// Assert
const templateContent = config.template.content;
const templateObj = JSON.parse(templateContent);
// Should preserve original
expect(templateObj.host).toBe('@host@');
expect(templateObj.name).toBe('test');
// Should add only missing
expect(templateObj['!!! port']).toBe('@port@');
expect(templateObj['!!! host']).toBeUndefined(); // Not added, already exists
});
});

View File

@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest';
/**
* Helper function that mimics the validation logic in ConfigTemplate.tsx
* This is the improved version with comment support
*/
function validateJsonWithComments(value: string): { valid: boolean; error?: string } {
try {
if (!value.trim()) {
return { valid: true };
}
// Step 1: Remove /* */ multi-line comments
let sanitized = value.replace(/\/\*[\s\S]*?\*\//g, '');
// Step 2: Process line by line to handle // comments outside quotes
const lines = sanitized.split('\n');
const processedLines = lines.map(line => {
let result = '';
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < line.length; i++) {
const char = line[i];
const prevChar = i > 0 ? line[i - 1] : '';
// Check for quote start/end (not escaped)
if ((char === '"' || char === "'") && prevChar !== '\\') {
if (!inQuote) {
inQuote = true;
quoteChar = char;
} else if (char === quoteChar) {
inQuote = false;
quoteChar = '';
}
result += char;
}
// Check for // comment start (only outside quotes)
else if (!inQuote && char === '/' && line[i + 1] === '/') {
break; // Rest of line is comment
}
else {
result += char;
}
}
return result;
});
sanitized = processedLines.join('\n');
// Step 3: Replace @placeholders@ with dummy values
sanitized = sanitized.replace(/@[^@]+@/g, '1');
JSON.parse(sanitized);
return { valid: true };
} catch (e) {
return { valid: false, error: (e as Error).message };
}
}
describe('JSON Validation - Comment Support', () => {
it('should remove single-line // comments outside quotes', () => {
const json = `{
"key": "value" // this is a comment
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should preserve // inside quoted strings', () => {
const json = `{
"url": "http://example.com/path",
"description": "This // that // other"
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should preserve URLs with // in values', () => {
const json = `{
"apiUrl": "http://localhost:8080/api",
"cdn": "https://cdn.example.com/assets"
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should remove multi-line /* */ comments', () => {
const json = `{
/* This is a
multi-line comment */
"key": "value"
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should handle mixed comments and placeholders', () => {
const json = `{
// Host configuration
"host": "@host@",
"port": @port@, // unquoted placeholder
/* API settings */
"apiUrl": "http://@host@:@port@/api" // URL with placeholders
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should handle comment-only lines', () => {
const json = `{
// Just a comment
"key": "value"
// Another comment
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should preserve // in string values with special characters', () => {
const json = `{
"regex": "pattern // with slashes",
"path": "C://Program Files//App",
"comment": "text // more text // even more"
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should handle escaped quotes correctly', () => {
const json = `{
"message": "He said \\"hello // world\\""
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should reject invalid JSON with comments', () => {
const json = `{
"key": "value" // missing comma
"another": "key"
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(false);
expect(result.error).toContain('Expected');
});
it('should handle empty template', () => {
const result = validateJsonWithComments('');
expect(result.valid).toBe(true);
});
it('should handle whitespace only', () => {
const result = validateJsonWithComments(' \n\t ');
expect(result.valid).toBe(true);
});
});
describe('JSON Validation - Placeholder Support', () => {
it('should validate quoted placeholders', () => {
const json = `{
"Host": "@host@",
"Port": "@port@"
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should validate unquoted placeholders', () => {
const json = `{
"Host": "@host@",
"Port": @port@
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should validate placeholders in URLs', () => {
const json = `{
"ApiUrl": "http://@host@:@port@/api/v1"
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
it('should validate complex template with comments and placeholders', () => {
const json = `{
// Database config
"db": {
"host": "@db_host@",
"port": @db_port@,
"url": "jdbc:mysql://@db_host@:@db_port@/mydb" // connection string
},
/* API settings */
"api": {
"baseUrl": "https://@api_host@/v1",
"timeout": @timeout@
}
}`;
const result = validateJsonWithComments(json);
expect(result.valid).toBe(true);
});
});

View File

@@ -1,13 +1,4 @@
{ {
"status": "failed", "status": "passed",
"failedTests": [ "failedTests": []
"2a84b67ca6571daf27b7-7e9801b0b833c50af541",
"2a84b67ca6571daf27b7-17ce5649ea02e0d09692",
"2a84b67ca6571daf27b7-46f1d47e52a9de8bb54e",
"2a84b67ca6571daf27b7-5ac3b387f0f8104531f1",
"2a84b67ca6571daf27b7-61b44892e9e54397b980",
"2a84b67ca6571daf27b7-e38411b679b6dd8a5f2c",
"2a84b67ca6571daf27b7-ca92b197b6f7ecc9f092",
"2a84b67ca6571daf27b7-0c2a356d8582be760d5f"
]
} }