Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0cd68b42b |
@@ -1,99 +0,0 @@
|
|||||||
# 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
|
|
||||||
[](https://git.six83.ru/ssa/configucci/actions/workflows/ci.yml)
|
|
||||||
[](https://git.six83.ru/ssa/configucci/actions/workflows/deploy.yml)
|
|
||||||
```
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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/
|
|
||||||
19
.gitea/workflows/demo.yaml
Normal file
19
.gitea/workflows/demo.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: Gitea Actions Demo
|
||||||
|
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Explore-Gitea-Actions:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
|
||||||
|
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
|
||||||
|
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||||
|
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
|
||||||
|
- name: List files in the repository
|
||||||
|
run: |
|
||||||
|
ls ${{ gitea.workspace }}
|
||||||
|
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -4,32 +4,29 @@ import * as fs from 'fs';
|
|||||||
test.describe('Environment Management', () => {
|
test.describe('Environment Management', () => {
|
||||||
test('should not allow removing DEFAULT environment', async ({ page }) => {
|
test('should not allow removing DEFAULT environment', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
await page.waitForTimeout(500);
|
const removeButton = page.locator('button.btn-danger[title="Remove environment"]');
|
||||||
const removeButton = page.locator('button[title="Remove environment"]');
|
|
||||||
await expect(removeButton).toBeDisabled();
|
await expect(removeButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove non-DEFAULT environment', async ({ page }) => {
|
test('should remove non-DEFAULT environment', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
await page.waitForTimeout(500);
|
|
||||||
page.once('dialog', async dialog => { await dialog.accept('toRemove'); });
|
page.once('dialog', async dialog => { await dialog.accept('toRemove'); });
|
||||||
await page.click('button[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await expect(page.locator('#environments option')).toHaveCount(2);
|
await expect(page.locator('#environments option')).toHaveCount(2);
|
||||||
page.once('dialog', async dialog => { await dialog.accept(); });
|
page.once('dialog', async dialog => { await dialog.accept(); });
|
||||||
await page.click('button[title="Remove environment"]');
|
await page.click('button.btn-danger[title="Remove environment"]');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await expect(page.locator('#environments option')).toHaveCount(1);
|
await expect(page.locator('#environments option')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create new environment and switch without errors', async ({ page }) => {
|
test('should create new environment and switch without errors', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
await page.waitForTimeout(500);
|
|
||||||
page.once('dialog', async dialog => { await dialog.accept('env1'); });
|
page.once('dialog', async dialog => { await dialog.accept('env1'); });
|
||||||
await page.click('button[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await expect(page.locator('#environments option')).toHaveCount(2);
|
await expect(page.locator('#environments option')).toHaveCount(2);
|
||||||
await page.locator('#environments').selectOption({ index: 0 });
|
await page.locator('#environments').selectOption({ index: 0 });
|
||||||
@@ -41,13 +38,12 @@ test.describe('Environment Management', () => {
|
|||||||
|
|
||||||
test('should create multiple environments and switch between them', async ({ page }) => {
|
test('should create multiple environments and switch between them', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
await page.waitForTimeout(500);
|
|
||||||
page.once('dialog', async dialog => { await dialog.accept('env1'); });
|
page.once('dialog', async dialog => { await dialog.accept('env1'); });
|
||||||
await page.click('button[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
page.once('dialog', async dialog => { await dialog.accept('env2'); });
|
page.once('dialog', async dialog => { await dialog.accept('env2'); });
|
||||||
await page.click('button[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await expect(page.locator('#environments option')).toHaveCount(3);
|
await expect(page.locator('#environments option')).toHaveCount(3);
|
||||||
await page.locator('#environments').selectOption({ index: 0 });
|
await page.locator('#environments').selectOption({ index: 0 });
|
||||||
@@ -61,11 +57,10 @@ test.describe('Environment Management', () => {
|
|||||||
|
|
||||||
test('should add params and edit template manually', async ({ page }) => {
|
test('should add params and edit template manually', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
await page.waitForTimeout(500);
|
const nameInput = page.locator('input[placeholder="name"]').first();
|
||||||
const nameInput = page.locator('input[placeholder="Parameter name"]').first();
|
const valueInput = page.locator('input[placeholder="value"]').first();
|
||||||
const valueInput = page.locator('input[placeholder="Parameter value"]').first();
|
const addButton = page.locator('button.btn-success').first();
|
||||||
const addButton = page.locator('button[title="Add parameter"]').first();
|
|
||||||
await nameInput.fill('host');
|
await nameInput.fill('host');
|
||||||
await valueInput.fill('localhost:8080');
|
await valueInput.fill('localhost:8080');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
@@ -74,49 +69,48 @@ test.describe('Environment Management', () => {
|
|||||||
await valueInput.fill('9090');
|
await valueInput.fill('9090');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.click('button:has-text("Content Template")');
|
await page.click('a:has-text("Content Template")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await expect(page.locator('button:has-text("Edit Template")')).toBeVisible();
|
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
|
||||||
await page.click('button:has-text("Edit Template")');
|
await page.click('button:has-text("Edit")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
const textarea = page.locator('textarea');
|
const textarea = page.locator('textarea');
|
||||||
await expect(textarea).toBeVisible();
|
await expect(textarea).toBeVisible();
|
||||||
await textarea.fill('{\n "!!! host": "@host@",\n "!!! port": "@port@",\n "!!! custom": "@custom@"\n}');
|
await textarea.fill('{\n "!!! host": "@host@",\n "!!! port": "@port@",\n "!!! custom": "@custom@"\n}');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await page.click('button:has-text("Save Changes")');
|
await page.click('button:has-text("Save")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await expect(page.locator('button:has-text("Edit Template")')).toBeVisible();
|
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
|
||||||
const pageContent = await page.content();
|
const pageContent = await page.content();
|
||||||
expect(pageContent).toContain('!!! custom');
|
expect(pageContent).toContain('!!! custom');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not duplicate params when placeholder already exists', async ({ page }) => {
|
test('should not duplicate params when placeholder already exists', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
await page.waitForTimeout(500);
|
const nameInput = page.locator('input[placeholder="name"]').first();
|
||||||
const nameInput = page.locator('input[placeholder="Parameter name"]').first();
|
const valueInput = page.locator('input[placeholder="value"]').first();
|
||||||
const valueInput = page.locator('input[placeholder="Parameter value"]').first();
|
const addButton = page.locator('button.btn-success').first();
|
||||||
const addButton = page.locator('button[title="Add parameter"]').first();
|
|
||||||
await nameInput.fill('host');
|
await nameInput.fill('host');
|
||||||
await valueInput.fill('localhost:8080');
|
await valueInput.fill('localhost:8080');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.click('button:has-text("Content Template")');
|
await page.click('a:has-text("Content Template")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.click('button:has-text("Edit Template")');
|
await page.click('button:has-text("Edit")');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const textarea = page.locator('textarea');
|
const textarea = page.locator('textarea');
|
||||||
await textarea.fill('{\n "!!! host": "@host@",\n "apiUrl": "http://@host@/api"\n}');
|
await textarea.fill('{\n "!!! host": "@host@",\n "apiUrl": "http://@host@/api"\n}');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await page.click('button:has-text("Save Changes")');
|
await page.click('button:has-text("Save")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.click('button:has-text("Env")');
|
await page.click('a:has-text("Env")');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await nameInput.fill('host');
|
await nameInput.fill('host');
|
||||||
await valueInput.fill('updated-host:9090');
|
await valueInput.fill('updated-host:9090');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.click('button:has-text("Content Template")');
|
await page.click('a:has-text("Content Template")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
const templateContent = await page.locator('.config-template-editor').textContent();
|
const templateContent = await page.locator('.config-template-editor').textContent();
|
||||||
const hostKeyCount = (templateContent.match(/!!! host/g) || []).length;
|
const hostKeyCount = (templateContent.match(/!!! host/g) || []).length;
|
||||||
@@ -127,24 +121,24 @@ test.describe('Environment Management', () => {
|
|||||||
|
|
||||||
test('should validate template with unquoted placeholders', async ({ page }) => {
|
test('should validate template with unquoted placeholders', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Add a parameter - use first() to get the new parameter inputs
|
// Add a parameter
|
||||||
await page.click('button[title="Add parameter"]');
|
await page.click('button:has-text("✚")');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const nameInput = page.locator('input[placeholder="Parameter name"]').last();
|
const nameInput = page.locator('input[placeholder="name"]');
|
||||||
const valueInput = page.locator('input[placeholder="Parameter value"]').last();
|
const valueInput = page.locator('input[placeholder="value"]');
|
||||||
const addButton = page.locator('button[title="Add parameter"]').last();
|
const addButton = page.locator('button:has-text("✓")');
|
||||||
await nameInput.fill('port');
|
await nameInput.fill('port');
|
||||||
await valueInput.fill('8080');
|
await valueInput.fill('8080');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Go to Content Template and edit with unquoted placeholder
|
// Go to Content Template and edit with unquoted placeholder
|
||||||
await page.click('button:has-text("Content Template")');
|
await page.click('a:has-text("Content Template")');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await page.click('button:has-text("Edit Template")');
|
await page.click('button:has-text("Edit")');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Fill template with unquoted @port@ placeholder
|
// Fill template with unquoted @port@ placeholder
|
||||||
@@ -153,11 +147,11 @@ test.describe('Environment Management', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Check that Save button is enabled (validation passed)
|
// Check that Save button is enabled (validation passed)
|
||||||
const saveButton = page.locator('button:has-text("Save Changes")');
|
const saveButton = page.locator('button:has-text("Save")');
|
||||||
await expect(saveButton).toBeEnabled();
|
await expect(saveButton).toBeEnabled();
|
||||||
|
|
||||||
// Check that there's no JSON error
|
// Check that there's no JSON error
|
||||||
const errorAlert = page.locator('.bg-red-50');
|
const errorAlert = page.locator('.alert-danger');
|
||||||
await expect(errorAlert).not.toBeVisible();
|
await expect(errorAlert).not.toBeVisible();
|
||||||
|
|
||||||
// Save the template
|
// Save the template
|
||||||
@@ -165,11 +159,11 @@ test.describe('Environment Management', () => {
|
|||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify it was saved - should be in view mode with Edit button visible
|
// Verify it was saved - should be in view mode with Edit button visible
|
||||||
const editButton = page.locator('button:has-text("Edit Template")');
|
const editButton = page.locator('button:has-text("Edit")');
|
||||||
await expect(editButton).toBeVisible();
|
await expect(editButton).toBeVisible();
|
||||||
|
|
||||||
// Verify the template content is displayed correctly
|
// Verify the template content is displayed correctly
|
||||||
const codeContent = page.locator('.hljs');
|
const codeContent = page.locator('code');
|
||||||
await expect(codeContent).toBeVisible();
|
await expect(codeContent).toBeVisible();
|
||||||
const content = await codeContent.textContent();
|
const content = await codeContent.textContent();
|
||||||
expect(content).toContain('@port@');
|
expect(content).toContain('@port@');
|
||||||
@@ -177,21 +171,8 @@ test.describe('Environment Management', () => {
|
|||||||
|
|
||||||
test('should download config file with correct filename', async ({ page }) => {
|
test('should download config file with correct filename', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("Create new")');
|
||||||
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")')
|
||||||
|
|||||||
@@ -3,31 +3,21 @@ 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 tseslint.config(
|
export default defineConfig([
|
||||||
{
|
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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
])
|
||||||
|
|||||||
1193
package-lock.json
generated
1193
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"lucide-react": "^0.575.0",
|
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-highlight": "^0.15.0"
|
"react-highlight": "^0.15.0"
|
||||||
@@ -25,14 +24,11 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-highlight": "^0.12.8",
|
"@types/react-highlight": "^0.12.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.24",
|
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^3.4.19",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
75
src/App.css
75
src/App.css
@@ -1,75 +0,0 @@
|
|||||||
/* App-specific styles - most styling is done with Tailwind */
|
|
||||||
|
|
||||||
/* Smooth scrolling */
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar for webkit browsers */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #f1f5f9;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #cbd5e1;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Highlight.js override for better dark mode support */
|
|
||||||
.hljs {
|
|
||||||
background: #fafafa !important;
|
|
||||||
padding: 1rem !important;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation utilities */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in {
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus visible for better accessibility */
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid #3b82f6;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transition utilities */
|
|
||||||
.transition-all {
|
|
||||||
transition-property: all;
|
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
transition-duration: 200ms;
|
|
||||||
}
|
|
||||||
|
|||||||
130
src/App.tsx
130
src/App.tsx
@@ -1,15 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react'
|
||||||
import { File } from 'lucide-react';
|
import './App.css'
|
||||||
import { Env } from './models/Env';
|
import 'bootstrap/dist/css/bootstrap.css'
|
||||||
import { Environment } from './componets/env/Environment';
|
import { Env } from './models/Env'
|
||||||
import { Content } from './componets/content/Content';
|
import Environment from "./componets/env"
|
||||||
import { FileChooser } from './componets/FileChooser';
|
import Content from './componets/content'
|
||||||
import { Config } from './models/Config';
|
import { FileChooser } from './componets/FileChooser'
|
||||||
|
import { Config } from "./models/Config"
|
||||||
|
import logo from './assets/cgg.png'
|
||||||
|
|
||||||
class AppState {
|
class AppState {
|
||||||
private constructor(
|
private constructor(
|
||||||
public config: Config = new Config(),
|
public config: Config = new Config(),
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
static readonly Instance = new AppState();
|
static readonly Instance = new AppState();
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ class AppState {
|
|||||||
// Simulate async save with 1 second delay
|
// Simulate async save with 1 second delay
|
||||||
return await new Promise<number>((resolve) => {
|
return await new Promise<number>((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('Saved env:', env.name);
|
console.log("Saved env:", env.name);
|
||||||
resolve(0);
|
resolve(0);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
@@ -39,9 +41,9 @@ function App() {
|
|||||||
|
|
||||||
async function handleEnvChanged(env: Env) {
|
async function handleEnvChanged(env: Env) {
|
||||||
// Optimistic update - update React state immediately
|
// Optimistic update - update React state immediately
|
||||||
setEnvs((prevEnvs) => {
|
setEnvs(prevEnvs => {
|
||||||
const newEnvs = [...prevEnvs];
|
const newEnvs = [...prevEnvs];
|
||||||
const idx = newEnvs.findIndex((x) => x.id === env.id);
|
const idx = newEnvs.findIndex(x => x.id === env.id);
|
||||||
if (idx > -1) {
|
if (idx > -1) {
|
||||||
newEnvs[idx] = env;
|
newEnvs[idx] = env;
|
||||||
}
|
}
|
||||||
@@ -49,9 +51,10 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Also update config.envs and template to keep them in sync
|
// Also update config.envs and template to keep them in sync
|
||||||
setConfig((prevConfig) => {
|
setConfig(prevConfig => {
|
||||||
const newConfig = new Config();
|
const newConfig = new Config();
|
||||||
newConfig.envs = prevConfig.envs.map((e) => (e.id === env.id ? env : e));
|
newConfig.envs = prevConfig.envs.map(e => e.id === env.id ? env : e);
|
||||||
|
// Update template JSON with params from this environment
|
||||||
newConfig.template = prevConfig.template;
|
newConfig.template = prevConfig.template;
|
||||||
newConfig.updateTemplateFromEnv(env);
|
newConfig.updateTemplateFromEnv(env);
|
||||||
return newConfig;
|
return newConfig;
|
||||||
@@ -67,8 +70,8 @@ function App() {
|
|||||||
|
|
||||||
function handleEnvAdded(env: Env): number {
|
function handleEnvAdded(env: Env): number {
|
||||||
const newIdx = envs.length;
|
const newIdx = envs.length;
|
||||||
setEnvs((prevEnvs) => [...prevEnvs, env]);
|
setEnvs(prevEnvs => [...prevEnvs, env]);
|
||||||
setConfig((prevConfig) => {
|
setConfig(prevConfig => {
|
||||||
const newConfig = new Config();
|
const newConfig = new Config();
|
||||||
newConfig.envs = [...prevConfig.envs, env];
|
newConfig.envs = [...prevConfig.envs, env];
|
||||||
newConfig.template = prevConfig.template;
|
newConfig.template = prevConfig.template;
|
||||||
@@ -78,80 +81,63 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEnvRemoved(envId: number) {
|
function handleEnvRemoved(envId: number) {
|
||||||
setEnvs((prevEnvs) => prevEnvs.filter((e) => e.id !== envId));
|
setEnvs(prevEnvs => prevEnvs.filter(e => e.id !== envId));
|
||||||
setConfig((prevConfig) => {
|
setConfig(prevConfig => {
|
||||||
const newConfig = new Config();
|
const newConfig = new Config();
|
||||||
newConfig.envs = prevConfig.envs.filter((e) => e.id !== envId);
|
newConfig.envs = prevConfig.envs.filter(e => e.id !== envId);
|
||||||
newConfig.template = prevConfig.template;
|
newConfig.template = prevConfig.template;
|
||||||
return newConfig;
|
return newConfig;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTemplateSaved(newContent: string) {
|
function handleTemplateSaved(newContent: string) {
|
||||||
setConfig((prevConfig) => {
|
setConfig(prevConfig => {
|
||||||
const newConfig = new Config();
|
const newConfig = new Config();
|
||||||
newConfig.envs = prevConfig.envs;
|
newConfig.envs = prevConfig.envs;
|
||||||
newConfig.setTemplate(newContent);
|
newConfig.addTemplate(newContent);
|
||||||
return newConfig;
|
return newConfig;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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-[1920px] min-h-[calc(100vh-48px)]">
|
<main className="container-fluid m-2">
|
||||||
{/* Header */}
|
<div className="row mb-2">
|
||||||
<div className="mb-6">
|
<FileChooser onSelected={x => {
|
||||||
<FileChooser
|
AppState.Instance.loadConfig(x);
|
||||||
onSelected={(x) => {
|
setEnvs(x.envs);
|
||||||
AppState.Instance.loadConfig(x);
|
setConfig(x);
|
||||||
setEnvs(x.envs);
|
}} config={config} />
|
||||||
setConfig(x);
|
|
||||||
}}
|
|
||||||
config={config}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{envs.length > 0 ?
|
||||||
{envs.length > 0 ? (
|
(<div className="row">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-[calc(100vh-200px)]">
|
<section id="env" className='col-4 me-1'>
|
||||||
{/* Environment Panel - 5/12 width */}
|
<Environment
|
||||||
<section className="lg:col-span-5 xl:col-span-5 2xl:col-span-5">
|
envs={envs}
|
||||||
<div className="sticky top-6 h-full">
|
onChanged={async (e) => await handleEnvChanged(e)}
|
||||||
<Environment
|
onSelected={handleEnvSelected}
|
||||||
envs={envs}
|
onAdd={handleEnvAdded}
|
||||||
onChanged={async (e) => await handleEnvChanged(e)}
|
onRemove={handleEnvRemoved} />
|
||||||
onSelected={handleEnvSelected}
|
</section>
|
||||||
onAdd={handleEnvAdded}
|
<section id="content" className="col-8 col-xl-7 border-start ms-1">
|
||||||
onRemove={handleEnvRemoved}
|
<Content env={currentEnv} config={config} onTemplateSaved={handleTemplateSaved} />
|
||||||
/>
|
</section>
|
||||||
|
</div>)
|
||||||
|
:
|
||||||
|
(
|
||||||
|
<div className="row justify-content-center pt-5" >
|
||||||
|
<div className="col-1 pt-5">
|
||||||
|
<img src={logo} alt="" style={{ opacity: 0.2, transform: 'scale(1.8)' }} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Content Panel - 7/12 width */}
|
|
||||||
<section className="lg:col-span-7 xl:col-span-7 2xl:col-span-7 h-full">
|
|
||||||
<Content
|
|
||||||
env={currentEnv}
|
|
||||||
config={config}
|
|
||||||
onTemplateSaved={handleTemplateSaved}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Empty State */
|
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
|
||||||
<div className="w-24 h-24 bg-gradient-to-br from-blue-400 to-blue-600 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
|
||||||
<File className="w-12 h-12 text-white opacity-80" />
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-slate-700 mb-2">
|
|
||||||
No Configuration Loaded
|
)}
|
||||||
</h2>
|
|
||||||
<p className="text-slate-500 text-center max-w-md">
|
|
||||||
Create a new configuration or upload an existing XML file to get started
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 (const p of this.src.params) {
|
for (let 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!")
|
||||||
|
|||||||
@@ -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> {
|
||||||
const b = new EnvBuilder();
|
let b = new EnvBuilder();
|
||||||
b.src = env;
|
b.src = env;
|
||||||
return b;
|
return b;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { HTMLAttributes, forwardRef } from 'react';
|
|
||||||
|
|
||||||
export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info';
|
|
||||||
|
|
||||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
||||||
variant?: BadgeVariant;
|
|
||||||
size?: 'sm' | 'md';
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantStyles: Record<BadgeVariant, string> = {
|
|
||||||
default: 'bg-slate-100 text-slate-700',
|
|
||||||
success: 'bg-green-100 text-green-800',
|
|
||||||
warning: 'bg-yellow-100 text-yellow-800',
|
|
||||||
danger: 'bg-red-100 text-red-800',
|
|
||||||
info: 'bg-blue-100 text-blue-800',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeStyles: Record<'sm' | 'md', string> = {
|
|
||||||
sm: 'px-1.5 py-0.5 text-xs',
|
|
||||||
md: 'px-2 py-1 text-sm',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
|
||||||
({ className = '', variant = 'default', size = 'sm', children, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
ref={ref}
|
|
||||||
className={`
|
|
||||||
inline-flex items-center font-medium rounded-full
|
|
||||||
${variantStyles[variant]}
|
|
||||||
${sizeStyles[size]}
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Badge.displayName = 'Badge';
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'success' | 'danger' | 'secondary' | 'ghost';
|
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: ButtonVariant;
|
|
||||||
size?: ButtonSize;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
|
||||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 shadow-md hover:shadow-lg',
|
|
||||||
success: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 shadow-md hover:shadow-lg',
|
|
||||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 shadow-md hover:shadow-lg',
|
|
||||||
secondary: 'bg-slate-200 text-slate-700 hover:bg-slate-300 focus:ring-slate-400',
|
|
||||||
ghost: 'bg-transparent text-slate-600 hover:bg-slate-100 focus:ring-slate-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeStyles: Record<ButtonSize, string> = {
|
|
||||||
sm: 'px-3 py-1.5 text-sm',
|
|
||||||
md: 'px-4 py-2 text-base',
|
|
||||||
lg: 'px-6 py-3 text-lg',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
className = '',
|
|
||||||
variant = 'primary',
|
|
||||||
size = 'md',
|
|
||||||
icon: Icon,
|
|
||||||
isLoading = false,
|
|
||||||
disabled,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const baseStyles = `
|
|
||||||
inline-flex items-center justify-center gap-2
|
|
||||||
font-medium rounded-lg
|
|
||||||
transition-all duration-200
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
${variantStyles[variant]}
|
|
||||||
${sizeStyles[size]}
|
|
||||||
${className}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button ref={ref} className={baseStyles} disabled={disabled || isLoading} {...props}>
|
|
||||||
{isLoading ? (
|
|
||||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
) : Icon ? (
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { HTMLAttributes, forwardRef } from 'react';
|
|
||||||
|
|
||||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
variant?: 'default' | 'bordered' | 'elevated';
|
|
||||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantStyles: Record<string, string> = {
|
|
||||||
default: 'bg-white',
|
|
||||||
bordered: 'bg-white border-2 border-slate-200',
|
|
||||||
elevated: 'bg-white shadow-lg',
|
|
||||||
};
|
|
||||||
|
|
||||||
const paddingStyles: Record<string, string> = {
|
|
||||||
none: '',
|
|
||||||
sm: 'p-3',
|
|
||||||
md: 'p-4',
|
|
||||||
lg: 'p-6',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
|
||||||
({ className = '', variant = 'default', padding = 'md', children, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={`rounded-xl overflow-hidden transition-all duration-300 ${variantStyles[variant]} ${paddingStyles[padding]} ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Card.displayName = 'Card';
|
|
||||||
|
|
||||||
type CardHeaderProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
|
||||||
({ className = '', children, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={`px-4 py-3 border-b border-slate-200 bg-slate-50 ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
CardHeader.displayName = 'CardHeader';
|
|
||||||
|
|
||||||
type CardBodyProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
|
|
||||||
({ className = '', children, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={`p-4 ${className}`} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
CardBody.displayName = 'CardBody';
|
|
||||||
|
|
||||||
type CardFooterProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
|
||||||
({ className = '', children, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={`px-4 py-3 border-t border-slate-200 bg-slate-50 ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
CardFooter.displayName = 'CardFooter';
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import Highlight from 'react-highlight';
|
|
||||||
import 'highlight.js/styles/atom-one-light.css';
|
|
||||||
|
|
||||||
interface CodeBlockProps {
|
|
||||||
code: string;
|
|
||||||
language?: 'json' | 'xml' | 'javascript' | 'typescript' | 'css' | 'html' | 'plaintext';
|
|
||||||
showLineNumbers?: boolean;
|
|
||||||
maxHeight?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodeBlock({
|
|
||||||
code,
|
|
||||||
language = 'plaintext',
|
|
||||||
showLineNumbers = false,
|
|
||||||
maxHeight = 'none',
|
|
||||||
className = '',
|
|
||||||
}: CodeBlockProps) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
await navigator.clipboard.writeText(code);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative group ${className}`}>
|
|
||||||
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="px-2 py-1 text-xs bg-slate-800 text-white rounded hover:bg-slate-700 transition-colors"
|
|
||||||
>
|
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="rounded-lg overflow-hidden border border-slate-200"
|
|
||||||
style={{ maxHeight, overflow: maxHeight !== 'none' ? 'auto' : 'visible' }}
|
|
||||||
>
|
|
||||||
<Highlight className={`language-${language} text-sm ${showLineNumbers ? 'line-numbers' : ''}`}>
|
|
||||||
{code || '// Empty'}
|
|
||||||
</Highlight>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { InputHTMLAttributes, forwardRef, useState } from 'react';
|
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ label, error, hint, icon, className = '', id, ...props }, ref) => {
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
|
|
||||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
|
|
||||||
const baseStyles = `
|
|
||||||
w-full px-3 py-2
|
|
||||||
border rounded-lg
|
|
||||||
transition-all duration-200
|
|
||||||
focus:outline-none focus:ring-2 focus:border-transparent
|
|
||||||
disabled:bg-slate-100 disabled:cursor-not-allowed
|
|
||||||
${error
|
|
||||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
|
||||||
: 'border-slate-300 focus:ring-blue-500 focus:border-blue-500'
|
|
||||||
}
|
|
||||||
${isFocused ? 'ring-2 ring-blue-500 border-transparent bg-blue-50' : 'bg-white'}
|
|
||||||
${icon ? 'pl-10' : ''}
|
|
||||||
${className}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
{label && (
|
|
||||||
<label htmlFor={inputId} className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{icon && (
|
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
id={inputId}
|
|
||||||
className={baseStyles}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hint && !error && (
|
|
||||||
<p className="mt-1 text-sm text-slate-500">{hint}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { SelectHTMLAttributes, forwardRef } from 'react';
|
|
||||||
|
|
||||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
options: { value: string | number; label: string; disabled?: boolean }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
|
||||||
({ label, error, options, className = '', id, ...props }, ref) => {
|
|
||||||
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
|
|
||||||
const baseStyles = `
|
|
||||||
w-full px-3 py-2
|
|
||||||
border rounded-lg
|
|
||||||
bg-white cursor-pointer
|
|
||||||
transition-all duration-200
|
|
||||||
focus:outline-none focus:ring-2 focus:border-transparent
|
|
||||||
disabled:bg-slate-100 disabled:cursor-not-allowed
|
|
||||||
${error
|
|
||||||
? 'border-red-300 focus:ring-red-500'
|
|
||||||
: 'border-slate-300 focus:ring-blue-500'
|
|
||||||
}
|
|
||||||
${className}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
{label && (
|
|
||||||
<label htmlFor={selectId} className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<select ref={ref} id={selectId} className={baseStyles} {...props}>
|
|
||||||
{options.map((option) => (
|
|
||||||
<option
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
disabled={option.disabled}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Select.displayName = 'Select';
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
export interface Tab {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
badge?: string | number;
|
|
||||||
badgeVariant?: 'default' | 'success' | 'warning' | 'danger';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TabsProps {
|
|
||||||
tabs: Tab[];
|
|
||||||
activeTab: string;
|
|
||||||
onChange: (tabId: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tabs({ tabs, activeTab, onChange, className = '', ...props }: TabsProps) {
|
|
||||||
return (
|
|
||||||
<div className={`border-b border-slate-200 ${className}`} {...props}>
|
|
||||||
<nav className="-mb-px flex gap-2" aria-label="Tabs">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = tab.id === activeTab;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => onChange(tab.id)}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg
|
|
||||||
border-b-2 transition-all duration-200
|
|
||||||
${isActive
|
|
||||||
? 'border-blue-500 text-blue-600 bg-white'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 hover:bg-slate-50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
{tab.badge !== undefined && (
|
|
||||||
<span
|
|
||||||
className={`
|
|
||||||
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
|
||||||
${tab.badgeVariant === 'danger' || tab.badgeVariant === 'warning'
|
|
||||||
? 'bg-red-100 text-red-800'
|
|
||||||
: 'bg-slate-100 text-slate-600'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{tab.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabPanel({ isActive, children, className = '', ...props }: TabPanelProps) {
|
|
||||||
if (!isActive) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`animate-fade-in ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export { Button } from './Button';
|
|
||||||
export type { ButtonVariant, ButtonSize } from './Button';
|
|
||||||
|
|
||||||
export { Input } from './Input';
|
|
||||||
|
|
||||||
export { Card, CardHeader, CardBody, CardFooter } from './Card';
|
|
||||||
|
|
||||||
export { Select } from './Select';
|
|
||||||
|
|
||||||
export { Badge } from './Badge';
|
|
||||||
export type { BadgeVariant } from './Badge';
|
|
||||||
|
|
||||||
export { Tabs, TabPanel } from './Tabs';
|
|
||||||
export type { Tab } from './Tabs';
|
|
||||||
|
|
||||||
export { CodeBlock } from './CodeBlock';
|
|
||||||
@@ -1,57 +1,41 @@
|
|||||||
import { useRef } from 'react';
|
import { Env } from "../models/Env";
|
||||||
import { Upload, Download, FilePlus, File } from 'lucide-react';
|
import { ConfigReader } from "../models/ConfigReader";
|
||||||
import { Button } from '../components/ui';
|
import { Config } from "../models/Config";
|
||||||
import { Config } from '../models/Config';
|
import { ConfigBuilder } from "../builders/ConfigBuilder";
|
||||||
import { ConfigReader } from '../models/ConfigReader';
|
|
||||||
import { ConfigBuilder } from '../builders/ConfigBuilder';
|
|
||||||
import { Env } from '../models/Env';
|
|
||||||
|
|
||||||
interface FileChooserProps {
|
export function FileChooser(props: { onSelected: (x: Config) => void, config?: Config }) {
|
||||||
onSelected: (config: Config) => void;
|
async function handleFile(x: React.ChangeEvent<HTMLInputElement>) {
|
||||||
config?: Config;
|
let file = x.target.files![0];
|
||||||
}
|
|
||||||
|
|
||||||
export function FileChooser({ onSelected, config }: FileChooserProps) {
|
console.log(file.name, file.type, file.size, "supported:", ConfigReader.isSupportedFormat(file));
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
let reader = new ConfigReader();
|
||||||
|
let cfg = await reader.parseFromFile(file);
|
||||||
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) {
|
if (cfg !== null) {
|
||||||
onSelected(cfg);
|
props.onSelected(cfg);
|
||||||
}
|
|
||||||
|
|
||||||
// Reset input
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNew() {
|
function handleNew(){
|
||||||
const cfg = new Config();
|
let cfg = new Config();
|
||||||
cfg.setEnvs([new Env(0, 'DEFAULT', [])]);
|
cfg.addEnvs([new Env(0, "DEFAULT", [])]);
|
||||||
cfg.setTemplate('{}');
|
cfg.addTemplate("{}");
|
||||||
onSelected(cfg);
|
props.onSelected(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (!config) {
|
if (!props.config) {
|
||||||
alert('No configuration loaded');
|
alert("No configuration loaded");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const xmlContent = ConfigBuilder.buildFullXml(config);
|
const xmlContent = ConfigBuilder.buildFullXml(props.config);
|
||||||
const filename = ConfigBuilder.generateFilename();
|
const filename = ConfigBuilder.generateFilename();
|
||||||
|
|
||||||
const blob = new Blob([xmlContent], { type: 'text/xml' });
|
// Create blob and download
|
||||||
|
const blob = new Blob([xmlContent], { type: "text/xml" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
@@ -60,63 +44,26 @@ export function FileChooser({ onSelected, config }: FileChooserProps) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasConfig = !!config && !config.isEmpty();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-md p-4 border border-slate-200">
|
<>
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="col-2">
|
||||||
{/* Logo/Brand */}
|
<button className="btn btn-primary" onClick={handleNew} >Create new</button>
|
||||||
<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="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={!hasConfig}
|
|
||||||
title={hasConfig ? 'Download full config template' : 'Load or create a config first'}
|
|
||||||
>
|
|
||||||
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>
|
||||||
</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="col">
|
||||||
|
<input className="form-control" type="file" id="formFile" onChange={handleFile} />
|
||||||
|
</div >
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { Pencil, Save, XCircle, CheckCircle } from 'lucide-react';
|
import Highlight from 'react-highlight';
|
||||||
import { Button, CodeBlock, Badge } from '../../components/ui';
|
import 'highlight.js/styles/far.css';
|
||||||
import { Config } from '../../models/Config';
|
import { Config } from "../../models/Config";
|
||||||
|
|
||||||
interface ConfigTemplateEditorProps {
|
interface ConfigTemplateProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
onSaved: (newContent: string) => void;
|
onSaved: (newContent: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorProps) {
|
export function ConfigTemplate(props: ConfigTemplateProps) {
|
||||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||||
const [draftContent, setDraftContent] = useState(config.template.content);
|
const [draftContent, setDraftContent] = useState(props.config.template.content);
|
||||||
const [originalContent, setOriginalContent] = useState(config.template.content);
|
const [originalContent, setOriginalContent] = useState(props.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') {
|
||||||
setDraftContent(config.template.content);
|
setDraftContent(props.config.template.content);
|
||||||
}
|
}
|
||||||
}, [config.template.content, mode]);
|
}, [props.config.template.content, mode]);
|
||||||
|
|
||||||
function handleEdit() {
|
function handleEdit() {
|
||||||
setOriginalContent(config.template.content);
|
setOriginalContent(props.config.template.content);
|
||||||
setDraftContent(config.template.content);
|
setDraftContent(props.config.template.content);
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
setMode('edit');
|
setMode('edit');
|
||||||
}
|
}
|
||||||
@@ -41,82 +34,36 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
setMode('view');
|
setMode('view');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateJson(value: string): boolean {
|
function handleSave() {
|
||||||
|
// Validate JSON before saving (with placeholder support)
|
||||||
try {
|
try {
|
||||||
if (!value.trim()) {
|
const sanitizedValue = draftContent.replace(/@[^@]+@/g, '1');
|
||||||
setJsonError(null);
|
JSON.parse(sanitizedValue);
|
||||||
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);
|
||||||
return true;
|
props.onSaved(draftContent);
|
||||||
|
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
|
||||||
validateJson(value);
|
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 {
|
||||||
|
setJsonError(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setJsonError((e as Error).message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
@@ -141,101 +88,57 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
const isValidJson = jsonError === null;
|
const isValidJson = jsonError === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-template-editor animate-fade-in h-full flex flex-col">
|
<div className="config-template-editor">
|
||||||
{mode === 'view' ? (
|
{mode === 'view' ? (
|
||||||
<div className="space-y-3 flex flex-col h-full">
|
<>
|
||||||
<div className="flex-shrink-0 flex items-center justify-between flex-wrap gap-3">
|
<div className="mb-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<button className="btn btn-primary btn-sm" onClick={handleEdit}>
|
||||||
<Badge variant="success">View Mode</Badge>
|
✎ Edit
|
||||||
{hasValidationWarnings && (
|
</button>
|
||||||
<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>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleEdit}
|
|
||||||
icon={Pencil}
|
|
||||||
>
|
|
||||||
Edit Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Highlight className="language-json">
|
||||||
<div className="flex-1 min-h-0">
|
{props.config.template.content || "{}"}
|
||||||
<CodeBlock code={config.template.content || '{}'} language="json" maxHeight="100%" />
|
</Highlight>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 flex flex-col h-full">
|
<>
|
||||||
<div className="flex-shrink-0 flex items-center gap-2 flex-wrap">
|
<div className="mb-2 d-flex gap-2 align-items-center">
|
||||||
<Button
|
<button
|
||||||
variant="success"
|
className="btn btn-success btn-sm"
|
||||||
size="sm"
|
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!isValidJson}
|
disabled={!isValidJson}
|
||||||
icon={Save}
|
|
||||||
>
|
>
|
||||||
Save Changes
|
✓ Save
|
||||||
</Button>
|
</button>
|
||||||
|
<button
|
||||||
<Button
|
className="btn btn-secondary btn-sm"
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRevert}
|
onClick={handleRevert}
|
||||||
icon={XCircle}
|
|
||||||
>
|
>
|
||||||
Revert
|
× Revert
|
||||||
</Button>
|
</button>
|
||||||
|
<span className={`ms-2 ${isValidJson ? 'text-success' : 'text-danger'}`}>
|
||||||
<Badge variant={isValidJson ? 'success' : 'danger'}>
|
{isValidJson ? 'Valid JSON' : 'Invalid JSON'}
|
||||||
{isValidJson ? (
|
</span>
|
||||||
<>
|
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
|
||||||
Valid JSON
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XCircle className="w-3 h-3 mr-1" />
|
|
||||||
Invalid JSON
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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 && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex-shrink-0">
|
<div className="alert alert-danger py-1 px-2 mb-2" style={{ fontSize: '0.875rem' }}>
|
||||||
<p className="text-sm text-red-700 font-mono">{jsonError}</p>
|
{jsonError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className={`
|
className={`form-control font-monospace ${isValidJson ? 'border-success' : 'border-danger'}`}
|
||||||
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}
|
value={draftContent}
|
||||||
onChange={(e) => handleDraftChange(e.target.value)}
|
onChange={(e) => handleDraftChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
rows={20}
|
rows={20}
|
||||||
style={{ whiteSpace: 'pre', overflowX: 'auto', flex: '1 1 auto', minHeight: '200px' }}
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
overflowX: 'auto'
|
||||||
|
}}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,152 +1,157 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { Tabs, TabPanel, CodeBlock } from '../../components/ui';
|
import { Env } from "../../models/Env";
|
||||||
import { Env } from '../../models/Env';
|
import Highlight from 'react-highlight'
|
||||||
import { Config } from '../../models/Config';
|
import 'highlight.js/styles/far.css'
|
||||||
import { ConfigTemplateEditor } from './ConfigTemplate';
|
import { Builder } from "../../builders";
|
||||||
import { Builder } from '../../builders';
|
import { Config } from "../../models/Config";
|
||||||
|
import { ConfigTemplate } from "./ConfigTemplate";
|
||||||
|
|
||||||
interface ContentProps {
|
|
||||||
config: Config;
|
export function Content(props: { config: Config, env: Env, onTemplateSaved: (newContent: string) => void }) {
|
||||||
env: Env;
|
const [selectTab, setTab] = useState(ContentType.Env);
|
||||||
onTemplateSaved: (newContent: string) => void;
|
|
||||||
|
// 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Content({ config, env, onTemplateSaved }: ContentProps) {
|
enum ContentType {
|
||||||
const [activeTab, setActiveTab] = useState('env');
|
Env = 0,
|
||||||
|
Json = 1,
|
||||||
// Validate placeholders for warning badge
|
Raw = 2,
|
||||||
const missingPlaceholders = config.validatePlaceholders();
|
Test = 3
|
||||||
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 h-full flex flex-col">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
|
||||||
<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 ContentParams({ env }: { env: Env }) {
|
function ContentTabs(props: { onSelected: (id: ContentType) => void, selectedTab: ContentType, hasValidationWarnings: boolean }) {
|
||||||
const xml = Builder.getEnv(env).build();
|
function clickHandler(type: ContentType) {
|
||||||
|
props.onSelected(type);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
function isActive(type: ContentType): string {
|
||||||
<div className="animate-fade-in h-full">
|
return type == props.selectedTab ? " active" : " ";
|
||||||
<CodeBlock code={xml} language="xml" maxHeight="100%" />
|
}
|
||||||
</div>
|
|
||||||
);
|
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 ContentRaw({ config }: { config: Config; env: Env }) {
|
function ContentRaw(props: { config: Config, env: Env }) {
|
||||||
const envsXml = Builder.getEnvs(config.envs);
|
const envsXml = Builder.getEnvs(props.config.envs);
|
||||||
const templateContent = config.template.content;
|
const templateContent = props.config.template.content;
|
||||||
|
|
||||||
const xml = `<engine>
|
const xml = `<engine>
|
||||||
${envsXml}
|
${envsXml}
|
||||||
<template>
|
<template>
|
||||||
${templateContent}
|
${templateContent}
|
||||||
</template>
|
</template>
|
||||||
</engine>`;
|
</engine>`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in h-full">
|
<>
|
||||||
<CodeBlock code={xml} language="xml" maxHeight="100%" />
|
<Highlight className="language-xml">
|
||||||
</div>
|
{xml}
|
||||||
);
|
</Highlight>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContentTest({ config, env }: { config: Config; env: Env }) {
|
function ContentTest(props: { config: Config, env: Env }) {
|
||||||
const [selectedEnvId, setSelectedEnvId] = useState(env.id ?? 0);
|
const [selectedEnvId, setSelectedEnvId] = useState(props.env.id);
|
||||||
const selectedEnv = config.envs.find(e => e.id === selectedEnvId) ?? env;
|
const selectedEnv = props.config.envs.find(e => e.id === selectedEnvId) ?? props.env;
|
||||||
|
|
||||||
const filledTemplate = fillTemplate(config, selectedEnv);
|
const filledTemplate = fillTemplate(props.config, selectedEnv);
|
||||||
|
|
||||||
const selectOptions = config.envs.map((e) => ({
|
return (
|
||||||
value: e.id ?? 0,
|
<>
|
||||||
label: e.name ?? 'Unknown',
|
<div className="mb-2">
|
||||||
}));
|
<label className="form-label">Select Environment:</label>
|
||||||
|
<select
|
||||||
return (
|
className="form-select w-auto d-inline-block"
|
||||||
<div className="animate-fade-in space-y-4 h-full flex flex-col">
|
value={selectedEnvId}
|
||||||
<div className="flex-shrink-0 flex items-center gap-2">
|
onChange={(e) => setSelectedEnvId(Number(e.target.value))}
|
||||||
<label className="text-sm font-medium text-slate-700">Select Environment:</label>
|
>
|
||||||
<select
|
{props.config.envs.map(env => (
|
||||||
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<option key={env.id} value={env.id}>{env.name}</option>
|
||||||
value={selectedEnvId}
|
))}
|
||||||
onChange={(e) => setSelectedEnvId(Number(e.target.value))}
|
</select>
|
||||||
>
|
</div>
|
||||||
{selectOptions.map((opt) => (
|
<Highlight className="language-json">
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
{filledTemplate}
|
||||||
))}
|
</Highlight>
|
||||||
</select>
|
</>
|
||||||
</div>
|
)
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<CodeBlock code={filledTemplate} language="json" maxHeight="100%" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillTemplate(config: Config, env: Env): string {
|
function fillTemplate(config: Config, env: Env): string {
|
||||||
const defaultEnv = config.envs.find((e) => e.name === 'DEFAULT');
|
const defaultEnv = config.envs.find(e => e.name === "DEFAULT");
|
||||||
const paramMap = new Map<string, string>();
|
const paramMap = new Map<string, string>();
|
||||||
|
|
||||||
// Load DEFAULT values first
|
// First, load DEFAULT values as fallback
|
||||||
if (defaultEnv) {
|
if (defaultEnv) {
|
||||||
for (const param of defaultEnv.params) {
|
for (const param of defaultEnv.params) {
|
||||||
if (param.name && param.value !== undefined) {
|
if (param.name && param.value !== undefined) {
|
||||||
paramMap.set(param.name, param.value);
|
paramMap.set(param.name, param.value);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Override with selected environment values
|
// Then, override with selected environment values (precedence)
|
||||||
for (const param of env.params) {
|
for (const param of env.params) {
|
||||||
if (param.name && param.value !== undefined) {
|
if (param.name && param.value !== undefined) {
|
||||||
paramMap.set(param.name, param.value);
|
paramMap.set(param.name, param.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let filledTemplate = config.template.content;
|
let filledTemplate = config.template.content;
|
||||||
const placeholderRegex = /@(\w+)@/g;
|
const placeholderRegex = /@(\w+)@/g;
|
||||||
|
|
||||||
filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => {
|
filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => {
|
||||||
if (paramName === Config.ENV_NAME_PARAM) {
|
if (paramName === Config.ENV_NAME_PARAM) {
|
||||||
return env.name ?? '--NO-VALUE--';
|
return env.name ?? "--NO-VALUE--";
|
||||||
}
|
}
|
||||||
return paramMap.get(paramName) ?? '--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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
242
src/componets/env/Environment.tsx
vendored
242
src/componets/env/Environment.tsx
vendored
@@ -1,157 +1,125 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { Plus, Minus } from 'lucide-react';
|
import { AddEvent, AppEvent, DelEvent, Env, UpdateEvent } from "../../models/Env";
|
||||||
import { Button, Select, Card, CardBody } from '../../components/ui';
|
import { EnvParam } from "../../models/EnvParam";
|
||||||
import { Env, AddEvent, RemoveEvent, UpdateEvent } from '../../models/Env';
|
import { EnvironmentParam } from "./EnvironmentParam";
|
||||||
import { EnvParam } from '../../models/EnvParam';
|
|
||||||
import { EnvironmentParam } from './EnvironmentParam';
|
|
||||||
|
|
||||||
interface EnvironmentProps {
|
export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, onSelected: (envId: number) => void, onAdd: (env: Env) => number, onRemove: (envId: number) => void }) {
|
||||||
envs: Env[];
|
const [currEnvId, setCurrEnvId] = useState(props.envs[0]?.id);
|
||||||
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) {
|
// Sync currEnvId when props.envs changes
|
||||||
const [currEnvId, setCurrEnvId] = useState<number>(envs[0]?.id ?? 0);
|
useEffect(() => {
|
||||||
|
if (!props.envs.find(e => e.id === currEnvId)) {
|
||||||
|
setCurrEnvId(props.envs[0]?.id);
|
||||||
|
}
|
||||||
|
}, [props.envs, currEnvId]);
|
||||||
|
|
||||||
// Sync currEnvId when envs changes
|
const currEnv = props.envs.find(e => e.id === currEnvId) ?? props.envs[0];
|
||||||
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(e: AppEvent<EnvParam>) {
|
||||||
|
let isChanged = false;
|
||||||
|
let env = currEnv;
|
||||||
|
|
||||||
function handleParamChanged(event: AddEvent<EnvParam> | RemoveEvent<EnvParam> | UpdateEvent<EnvParam>) {
|
if (e instanceof DelEvent) {
|
||||||
let newEnv: Env = currEnv;
|
env = currEnv.delParam(e.payload);
|
||||||
let isChanged = false;
|
isChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (event instanceof RemoveEvent) {
|
if (e instanceof AddEvent) {
|
||||||
newEnv = currEnv.delParam(event.payload);
|
env = currEnv.addParams(e.payload);
|
||||||
isChanged = true;
|
isChanged = true;
|
||||||
} else if (event instanceof AddEvent) {
|
}
|
||||||
newEnv = currEnv.addParams(event.payload);
|
|
||||||
isChanged = true;
|
if (e instanceof UpdateEvent) {
|
||||||
} else if (event instanceof UpdateEvent) {
|
env = currEnv.updateParams(e.payload);
|
||||||
newEnv = currEnv.updateParams(event.payload);
|
isChanged = true;
|
||||||
isChanged = true;
|
}
|
||||||
|
|
||||||
|
if (isChanged) {
|
||||||
|
props.onChanged(env);
|
||||||
|
setCurrEnvId(env.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isChanged) {
|
function handleAddEnv() {
|
||||||
onChanged(newEnv);
|
const name = prompt("Enter new environment name:");
|
||||||
setCurrEnvId(newEnv.id ?? 0);
|
if (!name || name.trim() === "") return;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddEnv() {
|
// Calculate next integer ID based on max existing ID
|
||||||
const name = prompt('Enter new environment name:');
|
const maxId = props.envs.reduce((max, e) => Math.max(max, e.id ?? 0), -1);
|
||||||
if (!name || name.trim() === '') return;
|
const newId = maxId + 1;
|
||||||
|
|
||||||
// Calculate next integer ID based on max existing ID
|
const newEnv = new Env(
|
||||||
const maxId = envs.reduce((max, e) => Math.max(max, e.id ?? 0), -1);
|
newId,
|
||||||
const newId = maxId + 1;
|
name.trim(),
|
||||||
|
[...currEnv.params]
|
||||||
const newEnv = new Env(newId, name.trim(), [...currEnv.params]);
|
);
|
||||||
const newIdx = onAdd(newEnv);
|
// Parent synchronously adds the env and returns the index
|
||||||
setCurrEnvId(newEnv.id ?? 0);
|
const newIdx = props.onAdd(newEnv);
|
||||||
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);
|
setCurrEnvId(newEnv.id);
|
||||||
}
|
props.onSelected(newIdx);
|
||||||
onSelected(newIdx);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const selectOptions = envs.map((x) => ({
|
function handleRemoveEnv() {
|
||||||
value: x.id ?? 0,
|
if (currEnv.isDefault()) {
|
||||||
label: x.name ?? 'Unknown',
|
alert("Cannot remove DEFAULT environment");
|
||||||
}));
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Remove environment "${currEnv.name}"?`)) return;
|
||||||
|
|
||||||
const paramCtrls = currEnv.params.map((x) => (
|
const idx = props.envs.findIndex(x => x.id === currEnv.id);
|
||||||
<EnvironmentParam
|
if (idx > -1 && currEnv.id !== undefined) {
|
||||||
key={`${currEnv.id}-${x.id}`}
|
// Let parent handle the removal
|
||||||
param={new EnvParam(x.id, x.name, x.value)}
|
props.onRemove(currEnv.id);
|
||||||
onChanged={handleParamChanged}
|
const newIdx = Math.max(0, idx - 1);
|
||||||
isNew={false}
|
const newEnv = props.envs[newIdx];
|
||||||
/>
|
if (newEnv?.id !== undefined) {
|
||||||
));
|
setCurrEnvId(newEnv.id);
|
||||||
|
}
|
||||||
|
props.onSelected(newIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const selectOptions = props.envs.map((x) => <option key={x.id} value={x.id} >{x.name}</option>);
|
||||||
<Card variant="bordered" padding="none" className="h-full overflow-hidden flex flex-col">
|
const paramCtrls = currEnv.params.map(x =>
|
||||||
<CardBody className="space-y-2 flex flex-col h-full overflow-hidden">
|
<EnvironmentParam key={`${currEnv.id}-${x.id}`}
|
||||||
{/* Environment Selector */}
|
param={new EnvParam(x.id, x.name, x.value)}
|
||||||
<div className="flex-shrink-0 flex gap-2">
|
onChanged={handleParamChanged}
|
||||||
<div className="flex-1">
|
isNew={false} />);
|
||||||
<Select
|
|
||||||
label="Environment"
|
|
||||||
value={currEnvId}
|
|
||||||
options={selectOptions}
|
|
||||||
onChange={(e) => {
|
|
||||||
const id = Number.parseInt(e.target.value);
|
|
||||||
setCurrEnvId(id);
|
|
||||||
onSelected(id);
|
|
||||||
}}
|
|
||||||
id="environments"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col justify-center gap-2 pt-6 flex-shrink-0">
|
return (
|
||||||
<div className="flex gap-2">
|
<>
|
||||||
<Button
|
<div className="row g-0">
|
||||||
variant="success"
|
<div className="col">
|
||||||
size="sm"
|
<select
|
||||||
onClick={handleAddEnv}
|
id="environments"
|
||||||
title="Add environment"
|
name="environments"
|
||||||
icon={Plus}
|
aria-label="Environments"
|
||||||
/>
|
className="form-select"
|
||||||
|
value={currEnvId}
|
||||||
<Button
|
onChange={x => {
|
||||||
variant="danger"
|
let id = Number.parseInt(x.target.value);
|
||||||
size="sm"
|
setCurrEnvId(id);
|
||||||
onClick={handleRemoveEnv}
|
props.onSelected(id);
|
||||||
title="Remove environment"
|
}}>
|
||||||
icon={Minus}
|
{selectOptions}
|
||||||
disabled={currEnv.isDefault()}
|
</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>
|
||||||
</div>
|
<div className="row">Params</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parameters Section - Scrollable */}
|
|
||||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
|
||||||
<h3 className="text-sm font-semibold text-slate-700 mb-1 uppercase tracking-wide flex-shrink-0">
|
|
||||||
Parameters
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-0 pr-2 -mr-2">
|
|
||||||
{paramCtrls}
|
{paramCtrls}
|
||||||
|
<EnvironmentParam key={`${currEnv.id}-new`}
|
||||||
<EnvironmentParam
|
param={new EnvParam(-1, "", "")}
|
||||||
key={`${currEnv.id}-new`}
|
onChanged={handleParamChanged}
|
||||||
param={new EnvParam(-1, '', '')}
|
isNew={true}
|
||||||
onChanged={handleParamChanged}
|
|
||||||
isNew={true}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
</div>
|
);
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
148
src/componets/env/EnvironmentParam.tsx
vendored
148
src/componets/env/EnvironmentParam.tsx
vendored
@@ -1,104 +1,68 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { Check, Minus } from 'lucide-react';
|
import { EnvParam } from "../../models/EnvParam";
|
||||||
import { Button, Input } from '../../components/ui';
|
import { AppEvent } from "../../models/Env";
|
||||||
import { EnvParam } from '../../models/EnvParam';
|
|
||||||
import { AddEvent, RemoveEvent, UpdateEvent } from '../../models/Env';
|
|
||||||
|
|
||||||
interface EnvironmentParamProps {
|
|
||||||
param: EnvParam;
|
|
||||||
onChanged: (event: AddEvent<EnvParam> | RemoveEvent<EnvParam> | UpdateEvent<EnvParam>) => void;
|
|
||||||
isNew: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamProps) {
|
export function EnvironmentParam(props: { param: EnvParam; onChanged: (e: AppEvent<EnvParam>) => void, isNew: boolean }) {
|
||||||
const [localParam, setLocalParam] = useState(param);
|
const [param, setParam] = useState(props.param);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
function updateParam(updates: Partial<EnvParam>) {
|
function doSet(x: string, act: (x: string) => void) {
|
||||||
const updated = localParam.update(updates).markChanged(true);
|
act(x);
|
||||||
setLocalParam(updated);
|
setParam(param.Changed(true));
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange() {
|
|
||||||
if (!localParam.isChanged) return;
|
|
||||||
|
|
||||||
const savedParam = localParam.markChanged(false);
|
|
||||||
|
|
||||||
if (!isNew) {
|
|
||||||
onChanged(UpdateEvent.update(savedParam));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalParam(savedParam);
|
function handleChange() {
|
||||||
}
|
if (!param.isChanged)
|
||||||
|
return;
|
||||||
|
|
||||||
function handleAdd() {
|
let newParam = param.Changed(false);
|
||||||
onChanged(AddEvent.add(localParam));
|
if (!props.isNew) {
|
||||||
setLocalParam(new EnvParam(0, '', ''));
|
props.onChanged(AppEvent.update(newParam));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
|
setParam(newParam);
|
||||||
if (event.key === 'Enter') {
|
|
||||||
handleChange();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const isChangedClass = localParam.isChanged ? 'ring-2 ring-yellow-400 border-yellow-400' : '';
|
function handleAdd() {
|
||||||
const focusedClass = isFocused ? 'bg-blue-50' : '';
|
props.onChanged(AppEvent.add(param));
|
||||||
|
setParam(new EnvParam(0, "", ""));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
function handleKeyUp(x: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
<div
|
if (x.key === "Enter") { handleChange(); }
|
||||||
className={`
|
}
|
||||||
grid grid-cols-12 gap-1 p-1 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">
|
return (
|
||||||
<Input
|
<div className={"row px-0" + (param.isChanged ? "border border-warning" : "")}
|
||||||
value={localParam.value ?? ''}
|
style={isFocused ? { backgroundColor: "lightskyblue", padding: "1px 0" } : { padding: "1px 0" }}>
|
||||||
onChange={(e) => updateParam({ value: e.target.value })}
|
<div className="col-4 mx-0 px-0">
|
||||||
onBlur={() => { handleChange(); setIsFocused(false); }}
|
<input type="text"
|
||||||
onFocus={() => setIsFocused(true)}
|
className="form-control"
|
||||||
onKeyUp={handleKeyUp}
|
style={{ backgroundColor: "rgba(170, 170, 247, 0.16)" }}
|
||||||
placeholder="Parameter value"
|
value={param.name}
|
||||||
className="text-sm"
|
onChange={x => doSet(x.target.value, (v) => param.name = v)}
|
||||||
/>
|
onBlur={() => { handleChange(); setIsFocused(false); }}
|
||||||
</div>
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
<div className="col-span-1 flex items-center justify-center">
|
placeholder="name"
|
||||||
{isNew ? (
|
aria-label="name" />
|
||||||
<Button
|
</div>
|
||||||
variant="success"
|
<div className="col mx-0 px-0">
|
||||||
size="sm"
|
<input type="text"
|
||||||
onClick={handleAdd}
|
className="form-control"
|
||||||
title="Add parameter"
|
value={param.value}
|
||||||
icon={Check}
|
onChange={x => doSet(x.target.value, v => param.value = v)}
|
||||||
className="px-2"
|
onBlur={() => { handleChange(); setIsFocused(false); }}
|
||||||
/>
|
onFocus={() => setIsFocused(true)}
|
||||||
) : (
|
onKeyUp={handleKeyUp}
|
||||||
<Button
|
placeholder="value"
|
||||||
variant="secondary"
|
aria-label="value" />
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => onChanged(new RemoveEvent(localParam))}
|
<div className="col-1 mx-0 px-0" >
|
||||||
title="Remove parameter"
|
<button className="btn btn-success" hidden={!props.isNew} onClick={handleAdd}>✓</button>
|
||||||
icon={Minus}
|
<button className="btn btn-warning" hidden={props.isNew} onClick={() => props.onChanged(AppEvent.del(param))} tabIndex={-1}>−</button>
|
||||||
className="px-2 text-red-600 hover:text-red-700 hover:bg-red-50"
|
</div>
|
||||||
/>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/index.css
139
src/index.css
@@ -1,139 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
body {
|
|
||||||
@apply bg-gradient-to-br from-slate-50 to-slate-100 min-h-screen;
|
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.btn {
|
|
||||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
@apply btn bg-primary-600 text-white hover:bg-primary-700
|
|
||||||
focus:ring-primary-500 shadow-md hover:shadow-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
@apply btn bg-success-600 text-white hover:bg-success-700
|
|
||||||
focus:ring-success-500 shadow-md hover:shadow-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
@apply btn bg-danger-600 text-white hover:bg-danger-700
|
|
||||||
focus:ring-danger-500 shadow-md hover:shadow-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
@apply btn bg-slate-200 text-slate-700 hover:bg-slate-300
|
|
||||||
focus:ring-slate-400 shadow-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
@apply px-3 py-1.5 text-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
@apply p-2 rounded-lg transition-all duration-200
|
|
||||||
hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-primary-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
@apply w-full px-3 py-2 border border-slate-300 rounded-lg
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
|
||||||
transition-all duration-200
|
|
||||||
placeholder:text-slate-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-focused {
|
|
||||||
@apply ring-2 ring-primary-500 border-transparent bg-primary-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply bg-white rounded-xl shadow-lg border border-slate-200
|
|
||||||
overflow-hidden transition-all duration-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
@apply px-4 py-3 border-b border-slate-200 bg-slate-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
@apply p-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select {
|
|
||||||
@apply w-full px-3 py-2 border border-slate-300 rounded-lg
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
|
||||||
bg-white cursor-pointer transition-all duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
@apply px-4 py-2 text-sm font-medium rounded-t-lg
|
|
||||||
transition-all duration-200 border-b-2 border-transparent
|
|
||||||
hover:bg-slate-100 cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-active {
|
|
||||||
@apply tab bg-white border-primary-500 text-primary-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-inactive {
|
|
||||||
@apply tab text-slate-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-warning {
|
|
||||||
@apply badge bg-warning-100 text-warning-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-success {
|
|
||||||
@apply badge bg-success-100 text-success-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-danger {
|
|
||||||
@apply badge bg-danger-100 text-danger-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
@apply block text-sm font-medium text-slate-700 mb-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in {
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #cbd5e1 transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #cbd5e1;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,178 +1,152 @@
|
|||||||
import { Env } from './Env';
|
import { Env } from "./Env";
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration template with placeholder support
|
|
||||||
*/
|
|
||||||
export class ConfigTemplate {
|
export class ConfigTemplate {
|
||||||
public static readonly Empty: ConfigTemplate = new ConfigTemplate();
|
public static Empty: ConfigTemplate = new ConfigTemplate();
|
||||||
|
|
||||||
private _contentText: string;
|
constructor(text: string = "") {
|
||||||
private _params: string[];
|
this._contentText = text;
|
||||||
|
this.extractParams();
|
||||||
constructor(contentText: string = '') {
|
|
||||||
this._contentText = contentText;
|
|
||||||
this._params = this.extractParams();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _contentText: string = "";
|
||||||
|
private _params: string[] = [];
|
||||||
|
|
||||||
public get content(): string {
|
public get content(): string {
|
||||||
return this._contentText;
|
return this._contentText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Backward compatibility getter
|
|
||||||
*/
|
|
||||||
public get Params(): string[] {
|
public get Params(): string[] {
|
||||||
return this.params;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get params(): string[] {
|
|
||||||
return [...this._params];
|
return [...this._params];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private extractParams() {
|
||||||
* Extracts @placeholder@ patterns from template content
|
let regex = /@(\w+)@/g;
|
||||||
*/
|
let matches;
|
||||||
private extractParams(): string[] {
|
let paramsSet = new Set<string>();
|
||||||
const regex = /@(\w+)@/g;
|
|
||||||
const paramsSet = new Set<string>();
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = regex.exec(this._contentText)) !== null) {
|
while ((matches = regex.exec(this._contentText)) !== null) {
|
||||||
paramsSet.add(match[1]);
|
if (matches.length > 1) {
|
||||||
|
paramsSet.add(matches[1]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(paramsSet);
|
this._params = Array.from(paramsSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Main configuration container
|
|
||||||
*/
|
|
||||||
export class Config {
|
export class Config {
|
||||||
public static readonly ENV_NAME_PARAM = 'env_name';
|
public static get ENV_NAME_PARAM(): string { return "env_name" };
|
||||||
|
|
||||||
public envs: Env[] = [];
|
public envs: Env[] = [];
|
||||||
public template: ConfigTemplate = ConfigTemplate.Empty;
|
public template: ConfigTemplate = ConfigTemplate.Empty;
|
||||||
|
|
||||||
/**
|
addEnvs(envs: Env[]) {
|
||||||
* Sets environments (backward compatibility)
|
|
||||||
*/
|
|
||||||
public addEnvs(envs: Env[]): void {
|
|
||||||
this.envs = envs;
|
this.envs = envs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
addTemplate(text: string) {
|
||||||
* Sets environments
|
this.template = new ConfigTemplate(text);
|
||||||
*/
|
|
||||||
public setEnvs(envs: Env[]): void {
|
|
||||||
this.envs = envs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getTemplateAsJson(): string {
|
||||||
* Sets template content (backward compatibility)
|
|
||||||
*/
|
|
||||||
public addTemplate(content: string): void {
|
|
||||||
this.setTemplate(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets template content
|
|
||||||
*/
|
|
||||||
public setTemplate(content: string): void {
|
|
||||||
this.template = new ConfigTemplate(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets template as JSON string
|
|
||||||
*/
|
|
||||||
public getTemplateAsJson(): string {
|
|
||||||
try {
|
try {
|
||||||
return this.template.content;
|
return this.template.content ;
|
||||||
} catch {
|
} catch (error) {
|
||||||
return '{}';
|
console.error("Error converting template content to JSON:", error);
|
||||||
|
return "{}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates template by adding placeholders for environment params
|
* Updates the template JSON by adding/updating params from the given environment.
|
||||||
|
* Params are added as "!!! paramName": "@paramName@" placeholder pairs.
|
||||||
|
* If a param's @placeholder@ already exists in template, it won't be added.
|
||||||
|
* Existing template content is preserved.
|
||||||
*/
|
*/
|
||||||
public updateTemplateFromEnv(env: Env): void {
|
updateTemplateFromEnv(env: Env) {
|
||||||
// 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
|
|
||||||
let templateObj: Record<string, any> = {};
|
let templateObj: Record<string, any> = {};
|
||||||
let hasExistingContent = false;
|
|
||||||
|
|
||||||
|
// Try to parse existing template as JSON
|
||||||
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 (e) {
|
||||||
// If invalid JSON, preserve the raw content and don't modify
|
// If parsing fails, start with empty object
|
||||||
return;
|
console.warn("Template is not valid JSON, starting fresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add placeholders for params that don't exist yet
|
// Add/update params from the environment as placeholders
|
||||||
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 paramName = param.name.trim();
|
const placeholderValue = `@${param.name}@`;
|
||||||
const placeholder = `@${paramName}@`;
|
|
||||||
const templateKey = `!!! ${paramName}`;
|
|
||||||
|
|
||||||
// Check if placeholder exists anywhere in template
|
// Check if this placeholder already exists anywhere in the template
|
||||||
if (!this.template.content.includes(placeholder)) {
|
const placeholderAlreadyExists = this.template.content.includes(placeholderValue);
|
||||||
// Only add if not already in templateObj
|
|
||||||
if (!templateObj[templateKey]) {
|
if (!placeholderAlreadyExists) {
|
||||||
templateObj[templateKey] = placeholder;
|
const placeholderKey = `!!! ${param.name}`;
|
||||||
hasChanges = true;
|
templateObj[placeholderKey] = placeholderValue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if there are actual changes
|
// Convert back to formatted JSON string
|
||||||
if (hasChanges || !hasExistingContent) {
|
const newTemplateContent = JSON.stringify(templateObj, null, 4);
|
||||||
this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4));
|
this.template = new ConfigTemplate(newTemplateContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateParams(): string[] {
|
||||||
|
const envKeys = this.envs.map(env => env.params.map(param => param.name)).flat();
|
||||||
|
const missingParams = this.template.Params.filter(param => param != Config.ENV_NAME_PARAM && !envKeys.includes(param));
|
||||||
|
|
||||||
|
if (missingParams.length > 0) {
|
||||||
|
console.error("Template: missing parameters in environments:", missingParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return missingParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that all template placeholders have corresponding params (backward compatibility)
|
* Validates that all @placeholders@ in template have corresponding params.
|
||||||
|
* Checks DEFAULT env first, then all custom envs.
|
||||||
|
* Returns array of placeholder names that are not defined.
|
||||||
*/
|
*/
|
||||||
public validateParams(): string[] {
|
validatePlaceholders(): string[] {
|
||||||
return this.validatePlaceholders();
|
const defaultEnv = this.envs.find(e => e.name === "DEFAULT");
|
||||||
}
|
const customEnvs = this.envs.filter(e => e.name !== "DEFAULT");
|
||||||
|
|
||||||
/**
|
// Collect all param names from DEFAULT
|
||||||
* Validates that all template placeholders have corresponding params
|
|
||||||
*/
|
|
||||||
public validatePlaceholders(): string[] {
|
|
||||||
const defaultEnv = this.envs.find(e => e.name === 'DEFAULT');
|
|
||||||
const customEnvs = this.envs.filter(e => e.name !== 'DEFAULT');
|
|
||||||
|
|
||||||
// Collect param names from DEFAULT
|
|
||||||
const defaultParamNames = new Set(
|
const defaultParamNames = new Set(
|
||||||
defaultEnv?.getParamNames() || []
|
defaultEnv?.params.map(p => p.name).filter(n => n && n.trim() !== "") || []
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collect param names from all custom envs
|
// Collect all param names from all custom envs
|
||||||
const customParamNames = new Set(
|
const customParamNames = new Set(
|
||||||
customEnvs.flatMap(e => e.getParamNames())
|
customEnvs.flatMap(e => e.params.map(p => p.name).filter(n => n && n.trim() !== ""))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find missing placeholders
|
// Extract all @placeholders@ from template
|
||||||
const missingParams: string[] = [];
|
const placeholderRegex = /@(\w+)@/g;
|
||||||
for (const placeholder of this.template.params) {
|
const placeholdersInTemplate = new Set<string>();
|
||||||
if (placeholder === Config.ENV_NAME_PARAM) continue;
|
let match;
|
||||||
|
while ((match = placeholderRegex.exec(this.template.content)) !== null) {
|
||||||
|
placeholdersInTemplate.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find placeholders that don't have matching params
|
||||||
|
const missingParams: string[] = [];
|
||||||
|
for (const placeholder of placeholdersInTemplate) {
|
||||||
|
if (placeholder === Config.ENV_NAME_PARAM) continue; // Skip built-in
|
||||||
|
|
||||||
|
// Check if exists in DEFAULT or in ANY custom env
|
||||||
const inDefault = defaultParamNames.has(placeholder);
|
const inDefault = defaultParamNames.has(placeholder);
|
||||||
const inCustom = customParamNames.has(placeholder);
|
const inCustom = customParamNames.has(placeholder);
|
||||||
|
|
||||||
|
// Valid if: in DEFAULT, or in at least one custom env
|
||||||
if (!inDefault && !inCustom) {
|
if (!inDefault && !inCustom) {
|
||||||
missingParams.push(placeholder);
|
missingParams.push(placeholder);
|
||||||
}
|
}
|
||||||
@@ -180,32 +154,5 @@ export class Config {
|
|||||||
|
|
||||||
return missingParams;
|
return missingParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a deep copy of the config
|
|
||||||
*/
|
|
||||||
public clone(): Config {
|
|
||||||
const cloned = new Config();
|
|
||||||
cloned.envs = [...this.envs];
|
|
||||||
cloned.template = this.template;
|
|
||||||
return cloned;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if config is empty (no environments or only DEFAULT with no params and empty template)
|
|
||||||
*/
|
|
||||||
public isEmpty(): boolean {
|
|
||||||
if (this.envs.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if only DEFAULT exists with no params
|
|
||||||
if (this.envs.length === 1 && this.envs[0].name === 'DEFAULT') {
|
|
||||||
const hasParams = this.envs[0].params.length > 0;
|
|
||||||
const hasTemplate = this.template.content.trim() && this.template.content !== '{}';
|
|
||||||
return !hasParams && !hasTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,115 +1,135 @@
|
|||||||
import { Env } from './Env';
|
import { Env } from "./Env";
|
||||||
import { EnvParam } from './EnvParam';
|
import { EnvParam } from "./EnvParam";
|
||||||
import { Config } from './Config';
|
import { Config } from "./Config";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XML Configuration Parser
|
* A utility class for parsing XML configuration files into a structured Config object.
|
||||||
* Parses XML files into Config objects
|
*
|
||||||
|
* Supports both string-based and file-based parsing, extracting environment definitions
|
||||||
|
* and their associated parameters. The expected XML format includes:
|
||||||
|
* - Root element with tag "engine"
|
||||||
|
* - Child elements "environment" with a "name" attribute
|
||||||
|
* - Nested "parameter" elements with "name" and "value" attributes
|
||||||
|
*
|
||||||
|
* Provides validation and error handling for missing attributes.
|
||||||
|
* Includes utility method to check if a file is in the supported XML format.
|
||||||
*/
|
*/
|
||||||
export class ConfigReader {
|
export class ConfigReader {
|
||||||
private readonly rootTag = 'engine';
|
private readonly rootTag = "engine";
|
||||||
private readonly envTag = 'environment';
|
|
||||||
private readonly envNameAttr = 'name';
|
private readonly envTag = "environment";
|
||||||
private readonly paramTag = 'parameter';
|
private readonly envNameAttr = "name";
|
||||||
private readonly paramNameAttr = 'name';
|
|
||||||
private readonly paramValAttr = 'value';
|
private readonly paramTag = "parameter";
|
||||||
private readonly templateTag = 'template';
|
private readonly paramNameAttr = "name";
|
||||||
|
private readonly paramValAttr = "value";
|
||||||
|
|
||||||
|
private readonly templateTag = "template";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses XML string into Config
|
* Parses an XML string into a Config object.
|
||||||
|
*
|
||||||
|
* @param xmlString - The XML content as a string
|
||||||
|
* @param fileType - The MIME type of the XML (default: 'application/xml')
|
||||||
|
* @returns A Config object containing parsed environments and parameters, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
public parseFromString(xmlString: string, fileType: DOMParserSupportedType = 'application/xml'): Config | null {
|
public parseFromString(xmlString: string, fileType: DOMParserSupportedType = 'application/xml'): Config | null {
|
||||||
const parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
const xml = parser.parseFromString(xmlString, fileType);
|
let xml = parser.parseFromString(xmlString, fileType);
|
||||||
this.validateTemplate(xml);
|
this.checkTemplate(xml);
|
||||||
|
let config = new Config();
|
||||||
|
|
||||||
const config = new Config();
|
let envs = this.parseEnvs(xml.querySelectorAll(`${this.rootTag}>${this.envTag}`));
|
||||||
const envs = this.parseEnvs(xml.querySelectorAll(`${this.rootTag}>${this.envTag}`));
|
config.addEnvs(envs);
|
||||||
config.setEnvs(envs);
|
|
||||||
|
|
||||||
const tmplElement = xml.getElementsByTagName(this.templateTag)[0];
|
let tmplElement = xml.getElementsByTagName(this.templateTag)[0];
|
||||||
const tmplText = tmplElement?.textContent?.trim();
|
let tmplText = tmplElement?.textContent?.trim();
|
||||||
if (!tmplText) {
|
if (!tmplText) {
|
||||||
throw new Error(`Template content is missing in <${this.templateTag}> element.`);
|
throw new Error(`Template content is missing or empty in <${this.templateTag}> element.`);
|
||||||
}
|
}
|
||||||
config.setTemplate(tmplText);
|
|
||||||
|
|
||||||
|
config.addTemplate(tmplText);
|
||||||
|
|
||||||
|
console.log("parsed from string res:", config);
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses XML file into Config
|
* Parses an XML file into a Config object asynchronously.
|
||||||
|
*
|
||||||
|
* @param file - The File object representing the XML file
|
||||||
|
* @returns A Promise resolving to a Config object or null if parsing fails
|
||||||
*/
|
*/
|
||||||
public async parseFromFile(file: File): Promise<Config | null> {
|
public async parseFromFile(file: File): Promise<Config | null> {
|
||||||
const srcText = await file.text();
|
let srcText = await file.text();
|
||||||
return this.parseFromString(srcText, file.type as DOMParserSupportedType);
|
return this.parseFromString(srcText, file.type as DOMParserSupportedType);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if file format is supported
|
|
||||||
*/
|
|
||||||
public static isSupportedFormat(file: File): boolean | string {
|
|
||||||
if (file.type !== 'text/xml') {
|
|
||||||
return `File format '${file.type}' not supported (expected text/xml)`;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses environment elements
|
|
||||||
*/
|
|
||||||
private parseEnvs(xmlEnvs: NodeListOf<Element>): Env[] {
|
private parseEnvs(xmlEnvs: NodeListOf<Element>): Env[] {
|
||||||
return Array.from(xmlEnvs).map((xml, index) => this.xmlToEnv(xml, index));
|
let res: Env[] = [];
|
||||||
|
let i = 0;
|
||||||
|
for (let xml of xmlEnvs) {
|
||||||
|
res.push(this.xmlToEnv(xml, i++));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwError(text: string): string {
|
||||||
|
throw new Error(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts XML element to Env
|
|
||||||
*/
|
|
||||||
private xmlToEnv(xml: Element, id: number): Env {
|
private xmlToEnv(xml: Element, id: number): Env {
|
||||||
const name = xml.getAttribute(this.envNameAttr);
|
let name = xml.getAttribute(this.envNameAttr) ?? this.throwError(`no attr '${this.envNameAttr}' in '${xml.tagName}'`);
|
||||||
if (!name) {
|
let params = this.parseParams(xml);
|
||||||
throw new Error(`Missing '${this.envNameAttr}' attribute in '${xml.tagName}'`);
|
|
||||||
}
|
|
||||||
const params = this.parseParams(xml);
|
|
||||||
return new Env(id, name, params);
|
return new Env(id, name, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses parameter elements
|
|
||||||
*/
|
|
||||||
private parseParams(xml: Element): EnvParam[] {
|
private parseParams(xml: Element): EnvParam[] {
|
||||||
return Array.from(xml.getElementsByTagName(this.paramTag))
|
let paramElements = xml.getElementsByTagName(this.paramTag);
|
||||||
.map((p, index) => this.xmlToParam(p, index));
|
let params: EnvParam[] = [];
|
||||||
|
let id = 0;
|
||||||
|
for (let p of paramElements) {
|
||||||
|
params.push(this.xmlToParam(p, id++));
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts XML element to EnvParam
|
|
||||||
*/
|
|
||||||
private xmlToParam(xmlParam: Element, id: number): EnvParam {
|
private xmlToParam(xmlParam: Element, id: number): EnvParam {
|
||||||
const name = xmlParam.getAttribute(this.paramNameAttr);
|
let name = xmlParam.getAttribute(this.paramNameAttr) ?? this.throwError(`no attr '${this.paramNameAttr}' in '${this.paramTag}'`);
|
||||||
const value = xmlParam.getAttribute(this.paramValAttr);
|
let val = xmlParam.getAttribute(this.paramValAttr) ?? this.throwError(`no attr '${this.paramValAttr}' in '${this.paramTag}'`);
|
||||||
|
|
||||||
if (!name) {
|
return new EnvParam(id, name, val);
|
||||||
throw new Error(`Missing '${this.paramNameAttr}' attribute in parameter`);
|
|
||||||
}
|
|
||||||
if (value === null) {
|
|
||||||
throw new Error(`Missing '${this.paramValAttr}' attribute in parameter`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new EnvParam(id, name, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates template element exists
|
* Checks if the given file is in a supported format (text/xml).
|
||||||
|
*
|
||||||
|
* @param file - The File object to check
|
||||||
|
* @returns True if the file is of type 'text/xml', otherwise returns an error message string
|
||||||
*/
|
*/
|
||||||
private validateTemplate(xml: Document): void {
|
public static isSupportedFormat(file: File): (boolean | string) {
|
||||||
const templates = xml.getElementsByTagName(this.templateTag);
|
if (file.type !== "text/xml") {
|
||||||
|
return `file format ${file.type} not supported (or extension is't .xml)`;
|
||||||
if (templates.length === 0) {
|
|
||||||
throw new Error(`Missing required <${this.templateTag}> element`);
|
|
||||||
}
|
}
|
||||||
if (templates.length > 1) {
|
|
||||||
throw new Error(`Multiple <${this.templateTag}> elements found. Only one allowed.`);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkTemplate(xml: Document) {
|
||||||
|
const templateElements = xml.getElementsByTagName(this.templateTag);
|
||||||
|
|
||||||
|
if (templateElements.length === 0) {
|
||||||
|
this.throwError(`Missing required <${this.templateTag}> element in the XML.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(templateElements.length > 1) {
|
||||||
|
this.throwError(`Multiple <${this.templateTag}> elements found. Only one is allowed.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,151 +1,60 @@
|
|||||||
import { EnvParam } from './EnvParam';
|
import { EnvParam } from "./EnvParam";
|
||||||
import { NamedEntity } from './types';
|
import { NamedId } from "./NamedId";
|
||||||
|
|
||||||
/**
|
|
||||||
* Environment configuration containing parameters
|
|
||||||
*/
|
|
||||||
export class Env implements NamedEntity {
|
|
||||||
constructor(
|
|
||||||
public id?: number,
|
|
||||||
public name?: string,
|
|
||||||
public params: EnvParam[] = []
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
export class Env implements NamedId {
|
||||||
* Checks if this is the DEFAULT environment
|
constructor(
|
||||||
*/
|
public id?: number,
|
||||||
public isDefault(): boolean {
|
public name?: string,
|
||||||
return this.name === 'DEFAULT';
|
public params: EnvParam[] = []
|
||||||
}
|
) { }
|
||||||
|
|
||||||
/**
|
public isDefault() {
|
||||||
* Adds a new parameter to the environment (backward compatibility)
|
return this.name === "DEFAULT";
|
||||||
*/
|
|
||||||
public addParams(param: EnvParam): Env {
|
|
||||||
return this.addParam(param);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new parameter to the environment
|
|
||||||
*/
|
|
||||||
public addParam(param: EnvParam): Env {
|
|
||||||
const newParam = new EnvParam(
|
|
||||||
param.id ?? this.generateId(),
|
|
||||||
param.name,
|
|
||||||
param.value,
|
|
||||||
param.isChanged
|
|
||||||
);
|
|
||||||
return new Env(this.id, this.name, [...this.params, newParam]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a parameter by ID
|
|
||||||
*/
|
|
||||||
public delParam(paramIdOrParam: number | EnvParam): Env {
|
|
||||||
const paramId = typeof paramIdOrParam === 'number' ? paramIdOrParam : paramIdOrParam.id;
|
|
||||||
return this.removeParam(paramId!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a parameter by ID
|
|
||||||
*/
|
|
||||||
public removeParam(paramId: number): Env {
|
|
||||||
return new Env(
|
|
||||||
this.id,
|
|
||||||
this.name,
|
|
||||||
this.params.filter(p => p.id !== paramId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an existing parameter (backward compatibility)
|
|
||||||
*/
|
|
||||||
public updateParams(updatedParam: EnvParam): Env {
|
|
||||||
return this.updateParam(updatedParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an existing parameter
|
|
||||||
*/
|
|
||||||
public updateParam(updatedParam: EnvParam): Env {
|
|
||||||
const index = this.params.findIndex(p => p.id === updatedParam.id);
|
|
||||||
if (index === -1) {
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newParams = [...this.params];
|
addParams(payload: EnvParam): Env {
|
||||||
newParams[index] = updatedParam;
|
payload.id = Math.random() * 10000;
|
||||||
return new Env(this.id, this.name, newParams);
|
this.params.push(payload);
|
||||||
}
|
return new Env(this.id, this.name, [...this.params]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
delParam(param: EnvParam): Env {
|
||||||
* Gets a parameter by name
|
let idx = this.params.findIndex(el => el.id === param.id);
|
||||||
*/
|
if (idx > -1) {
|
||||||
public getParamByName(name: string): EnvParam | undefined {
|
const newP = this.params.filter(el => el.id !== param.id);
|
||||||
return this.params.find(p => p.name === name);
|
return new Env(this.id, this.name, newP);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return this;
|
||||||
* Gets all parameter names
|
}
|
||||||
*/
|
|
||||||
public getParamNames(): string[] {
|
|
||||||
return this.params.map(p => p.name).filter((n): n is string => !!n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
public updateParams(param: EnvParam): Env {
|
||||||
* Creates a copy with updated values
|
let idx = this.params.findIndex(el => el.id === param.id);
|
||||||
*/
|
if (idx > -1) {
|
||||||
public update(updates: Partial<Env>): Env {
|
let newP = [...this.params];
|
||||||
return new Env(
|
newP[idx] = param;
|
||||||
updates.id ?? this.id,
|
return new Env(this.id, this.name, newP);
|
||||||
updates.name ?? this.name,
|
}
|
||||||
updates.params ?? this.params
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return this;
|
||||||
* Generates a unique ID for new parameters
|
}
|
||||||
*/
|
|
||||||
private generateId(): number {
|
|
||||||
return Date.now() % 100000 + Math.floor(Math.random() * 10000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for environment events (backward compatibility)
|
|
||||||
*/
|
|
||||||
export class AppEvent<T> {
|
export class AppEvent<T> {
|
||||||
constructor(public payload: T) {}
|
protected constructor(public payload: T) { }
|
||||||
|
|
||||||
public static add<T>(payload: T): AppEvent<T> {
|
public static add<T>(payload: T): AppEvent<T> {
|
||||||
return new AddEvent(payload);
|
return new AddEvent(payload);
|
||||||
}
|
}
|
||||||
|
public static del<T>(payload: T): AppEvent<T> {
|
||||||
public static del<T>(payload: T): AppEvent<T> {
|
return new DelEvent(payload);
|
||||||
return new RemoveEvent(payload);
|
}
|
||||||
}
|
public static update<T>(payload: T): AppEvent<T> {
|
||||||
|
return new UpdateEvent(payload);
|
||||||
public static update<T>(payload: T): AppEvent<T> {
|
}
|
||||||
return new UpdateEvent(payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export class AddEvent<T> extends AppEvent<T> { }
|
||||||
* Event for adding a parameter
|
export class UpdateEvent<T> extends AppEvent<T> { }
|
||||||
*/
|
export class DelEvent<T> extends AppEvent<T> { }
|
||||||
export class AddEvent<T> extends AppEvent<T> {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event for updating a parameter
|
|
||||||
*/
|
|
||||||
export class UpdateEvent<T> extends AppEvent<T> {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event for removing a parameter (backward compatibility)
|
|
||||||
*/
|
|
||||||
export class DelEvent<T> extends AppEvent<T> {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event for removing a parameter
|
|
||||||
*/
|
|
||||||
export class RemoveEvent<T> extends AppEvent<T> {}
|
|
||||||
@@ -1,73 +1,32 @@
|
|||||||
import { NamedEntity } from './types';
|
import { NamedId } from "./NamedId";
|
||||||
|
|
||||||
/**
|
export class EnvParam implements NamedId {
|
||||||
* Environment parameter representing a key-value configuration pair
|
constructor(
|
||||||
*/
|
public id?: number,
|
||||||
export class EnvParam implements NamedEntity {
|
public name?: string,
|
||||||
constructor(
|
public value?: string,
|
||||||
public id?: number,
|
public isChanged: boolean = false
|
||||||
public name?: string,
|
) { }
|
||||||
public value?: string,
|
|
||||||
public isChanged: boolean = false
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
public Changed(v: boolean = true): EnvParam {
|
||||||
* Marks the parameter as changed (backward compatibility)
|
return new EnvParam(
|
||||||
*/
|
this.id,
|
||||||
public Changed(changed: boolean = true): EnvParam {
|
this.name,
|
||||||
return this.markChanged(changed);
|
this.value,
|
||||||
}
|
v);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
public sanitize(v?: string): string {
|
||||||
* Marks the parameter as changed
|
return v?.replace(/&/g, "&")
|
||||||
*/
|
.replace(/</g, '<')
|
||||||
public markChanged(changed: boolean = true): EnvParam {
|
.replace(/>/g, '>')
|
||||||
return new EnvParam(this.id, this.name, this.value, changed);
|
.replace(/"/g, '"')
|
||||||
}
|
.replace(/'/g, ''')
|
||||||
|
?? "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
public humanize(v?: string): string {
|
||||||
* Creates a copy of this parameter with updated values
|
return v ?? "";
|
||||||
*/
|
}
|
||||||
public update(updates: Partial<EnvParam>): EnvParam {
|
|
||||||
return new EnvParam(
|
|
||||||
updates.id ?? this.id,
|
|
||||||
updates.name ?? this.name,
|
|
||||||
updates.value ?? this.value,
|
|
||||||
updates.isChanged ?? this.isChanged
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes HTML special characters (instance method for backward compatibility)
|
|
||||||
*/
|
|
||||||
public sanitize(value?: string): string {
|
|
||||||
const v = value ?? this.value ?? '';
|
|
||||||
return EnvParam.sanitize(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes HTML special characters (static method)
|
|
||||||
*/
|
|
||||||
public static sanitize(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Humanizes the value (backward compatibility)
|
|
||||||
*/
|
|
||||||
public humanize(value?: string): string {
|
|
||||||
return value ?? this.value ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that the parameter has required fields
|
|
||||||
*/
|
|
||||||
public isValid(): boolean {
|
|
||||||
return !!(this.name && this.name.trim() !== '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Base interface for entities with optional ID
|
|
||||||
*/
|
|
||||||
export interface Entity {
|
|
||||||
id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Named entity with optional ID
|
|
||||||
*/
|
|
||||||
export interface NamedEntity extends Entity {
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
@@ -36,8 +36,8 @@ const cfgTemplate = `
|
|||||||
|
|
||||||
|
|
||||||
test("read from a file", async ({expect})=>{
|
test("read from a file", async ({expect})=>{
|
||||||
const sut = new ConfigReader();
|
let sut = new ConfigReader();
|
||||||
const file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'});
|
let 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
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = await sut.parseFromFile(file);
|
let 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})=>{
|
||||||
const sut = new ConfigReader();
|
let sut = new ConfigReader();
|
||||||
|
|
||||||
const cfg = sut.parseFromString(cfgTemplate);
|
let 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})=>{
|
||||||
const sut = new ConfigReader();
|
let sut = new ConfigReader();
|
||||||
|
|
||||||
const cfg = sut.parseFromString(cfgTemplate);
|
let 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);
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#eff6ff',
|
|
||||||
100: '#dbeafe',
|
|
||||||
200: '#bfdbfe',
|
|
||||||
300: '#93c5fd',
|
|
||||||
400: '#60a5fa',
|
|
||||||
500: '#3b82f6',
|
|
||||||
600: '#2563eb',
|
|
||||||
700: '#1d4ed8',
|
|
||||||
800: '#1e40af',
|
|
||||||
900: '#1e3a8a',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
50: '#f0fdf4',
|
|
||||||
100: '#dcfce7',
|
|
||||||
200: '#bbf7d0',
|
|
||||||
300: '#86efac',
|
|
||||||
400: '#4ade80',
|
|
||||||
500: '#22c55e',
|
|
||||||
600: '#16a34a',
|
|
||||||
700: '#15803d',
|
|
||||||
800: '#166534',
|
|
||||||
900: '#14532d',
|
|
||||||
},
|
|
||||||
danger: {
|
|
||||||
50: '#fef2f2',
|
|
||||||
100: '#fee2e2',
|
|
||||||
200: '#fecaca',
|
|
||||||
300: '#fca5a5',
|
|
||||||
400: '#f87171',
|
|
||||||
500: '#ef4444',
|
|
||||||
600: '#dc2626',
|
|
||||||
700: '#b91c1c',
|
|
||||||
800: '#991b1b',
|
|
||||||
900: '#7f1d1d',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
50: '#fffbeb',
|
|
||||||
100: '#fef3c7',
|
|
||||||
200: '#fde68a',
|
|
||||||
300: '#fcd34d',
|
|
||||||
400: '#fbbf24',
|
|
||||||
500: '#f59e0b',
|
|
||||||
600: '#d97706',
|
|
||||||
700: '#b45309',
|
|
||||||
800: '#92400e',
|
|
||||||
900: '#78350f',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
|
||||||
'slide-in': 'slideIn 0.3s ease-out',
|
|
||||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
fadeIn: {
|
|
||||||
'0%': { opacity: '0' },
|
|
||||||
'100%': { opacity: '1' },
|
|
||||||
},
|
|
||||||
slideIn: {
|
|
||||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user