From 4b8f5f3739eb5d0590577ff08d643399da524d1f Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 14:04:03 +0300 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC=D0=B8=20=D0=B8=20Playwright=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/environment.spec.ts | 73 ++++++++++++++++++++ package-lock.json | 64 +++++++++++++++++ package.json | 4 +- playwright-report/index.html | 85 +++++++++++++++++++++++ playwright.config.ts | 14 ++++ src/App.tsx | 41 +++++++++-- src/componets/content/Content.tsx | 2 +- src/componets/env/Environment.tsx | 91 ++++++++++++++++++------ src/test/Environment.test.tsx | 111 ++++++++++++++++++++++++++++++ test-results/.last-run.json | 4 ++ vitest.config.ts | 3 +- 11 files changed, 464 insertions(+), 28 deletions(-) create mode 100644 e2e/environment.spec.ts create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 src/test/Environment.test.tsx create mode 100644 test-results/.last-run.json diff --git a/e2e/environment.spec.ts b/e2e/environment.spec.ts new file mode 100644 index 0000000..630005d --- /dev/null +++ b/e2e/environment.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Environment Management', () => { + test('should not allow removing DEFAULT environment', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + + // Try to remove DEFAULT - should be blocked + const removeButton = page.locator('button.btn-danger[title="Remove environment"]'); + + // The button should be disabled for DEFAULT + await expect(removeButton).toBeDisabled(); + }); + + test('should remove non-DEFAULT environment', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + + // Create a new environment + page.once('dialog', async dialog => { + await dialog.accept('toRemove'); + }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + + // Verify we have 2 envs + await expect(page.locator('#environments option')).toHaveCount(2); + + // Remove the new environment + page.once('dialog', async dialog => { + await dialog.accept(); // Confirm removal + }); + await page.click('button.btn-danger[title="Remove environment"]'); + await page.waitForTimeout(300); + + // Verify we're back to 1 env (DEFAULT) + await expect(page.locator('#environments option')).toHaveCount(1); + await expect(page.locator('#environments')).toContainText('DEFAULT'); + }); + + test('should create new environment and switch without errors', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + + // Verify DEFAULT environment is loaded + await expect(page.locator('#environments')).toContainText('DEFAULT'); + + // Create a new environment + page.once('dialog', async dialog => { + await dialog.accept('env1'); + }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + + // Verify new environment is created + await expect(page.locator('#environments option')).toHaveCount(2); + + // Switch back to DEFAULT (by index 0) + await page.locator('#environments').selectOption({ index: 0 }); + await page.waitForTimeout(300); + + // Verify the page is still working + await expect(page.locator('#environments')).toBeVisible(); + + // Switch to env1 (by text) - this should NOT cause error + await page.locator('#environments').selectOption('env1'); + await page.waitForTimeout(300); + + // Verify the page is still working (no white screen of death) + await expect(page.locator('#environments')).toBeVisible(); + await expect(page.locator('#environments option')).toHaveCount(2); + }); +}); diff --git a/package-lock.json b/package-lock.json index ff08922..9ee2cc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@playwright/test": "^1.58.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-highlight": "^0.12.8", @@ -1228,6 +1229,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3383,6 +3400,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index d439896..0fe473b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "bootstrap": "^5.3.3", @@ -18,6 +19,7 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@playwright/test": "^1.58.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-highlight": "^0.12.8", diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..d9c09ad --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..27a5141 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, +}); diff --git a/src/App.tsx b/src/App.tsx index 1d36842..17a6d07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,11 @@ class AppState { this.config = cfg; } + public addEnv(env: Env): number { + this.envs.push(env); + return this.envs.length - 1; + } + public async saveEnv(env: Env): Promise { // Create a promise that resolves after 1 second @@ -43,11 +48,37 @@ function App() { const [selectedEnv, setSelectedEnv] = useState(0); const [config, setConfig] = useState(AppState.Instance.config); + // Ensure selectedEnv is always valid + const validSelectedEnv = Math.min(selectedEnv, Math.max(0, envs.length - 1)); + const currentEnv = envs[validSelectedEnv]; + async function handleEnvChanged(env: Env) { - let idx = await AppState.Instance.saveEnv(env); + // Synchronously update the env in the array + let idx = AppState.Instance.envs.findIndex(x => x.id === env.id); if (idx > -1) { + AppState.Instance.envs[idx] = env; + setEnvs([...AppState.Instance.envs]); + } + + // Then do the async save (for consistency with existing behavior) + await AppState.Instance.saveEnv(env); + } + + function handleEnvSelected(idx: number) { + setSelectedEnv(idx); + } + + function handleEnvAdded(env: Env): number { + const idx = AppState.Instance.addEnv(env); + setEnvs([...AppState.Instance.envs]); + return idx; + } + + function handleEnvRemoved(envId: number) { + const idx = AppState.Instance.envs.findIndex(x => x.id === envId); + if (idx > -1) { + AppState.Instance.envs.splice(idx, 1); setEnvs([...AppState.Instance.envs]); - setSelectedEnv(idx); } } @@ -67,10 +98,12 @@ function App() { await handleEnvChanged(e)} - onSelected={x => setSelectedEnv(x)} /> + onSelected={handleEnvSelected} + onAdd={handleEnvAdded} + onRemove={handleEnvRemoved} />
- +
) : diff --git a/src/componets/content/Content.tsx b/src/componets/content/Content.tsx index df628e9..8653428 100644 --- a/src/componets/content/Content.tsx +++ b/src/componets/content/Content.tsx @@ -129,7 +129,7 @@ function fillTemplate(config: Config, env: Env): string { let filledTemplate = config.template.content; const placeholderRegex = /@(\w+)@/g; - filledTemplate = filledTemplate.replace(placeholderRegex, (match, paramName) => { + filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => { if (paramName === Config.ENV_NAME_PARAM) { return env.name ?? "--NO-VALUE--"; } diff --git a/src/componets/env/Environment.tsx b/src/componets/env/Environment.tsx index 3920d7e..9fd69f7 100644 --- a/src/componets/env/Environment.tsx +++ b/src/componets/env/Environment.tsx @@ -1,10 +1,19 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { AddEvent, AppEvent, DelEvent, Env, UpdateEvent } from "../../models/Env"; import { EnvParam } from "../../models/EnvParam"; import { EnvironmentParam } from "./EnvironmentParam"; -export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, onSelected: (envId: number) => void }) { - const [currEnv, setCurrEnv] = useState(props.envs[0]); +export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, onSelected: (envId: number) => void, onAdd: (env: Env) => number, onRemove: (envId: number) => void }) { + const [currEnvId, setCurrEnvId] = useState(props.envs[0]?.id); + + // Sync currEnvId when props.envs changes + useEffect(() => { + if (!props.envs.find(e => e.id === currEnvId)) { + setCurrEnvId(props.envs[0]?.id); + } + }, [props.envs, currEnvId]); + + const currEnv = props.envs.find(e => e.id === currEnvId) ?? props.envs[0]; function handleParamChanged(e: AppEvent) { let isChanged = false; @@ -26,12 +35,43 @@ export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, } if (isChanged) { - let idx = props.envs.findIndex(x => x.id === env.id); - if (idx > -1) { - props.envs[idx] = env; - props.onChanged(props.envs[idx]); - setCurrEnv(env); + props.onChanged(env); + setCurrEnvId(env.id); + } + } + + function handleAddEnv() { + const name = prompt("Enter new environment name:"); + if (!name || name.trim() === "") return; + + const newEnv = new Env( + Math.random() * 10000, + name.trim(), + [...currEnv.params] + ); + // Parent synchronously adds the env and returns the index + const newIdx = props.onAdd(newEnv); + setCurrEnvId(newEnv.id); + props.onSelected(newIdx); + } + + function handleRemoveEnv() { + if (currEnv.isDefault()) { + alert("Cannot remove DEFAULT environment"); + return; + } + if (!confirm(`Remove environment "${currEnv.name}"?`)) return; + + const idx = props.envs.findIndex(x => x.id === currEnv.id); + if (idx > -1 && currEnv.id !== undefined) { + // Let parent handle the removal + props.onRemove(currEnv.id); + const newIdx = Math.max(0, idx - 1); + const newEnv = props.envs[newIdx]; + if (newEnv?.id !== undefined) { + setCurrEnvId(newEnv.id); } + props.onSelected(newIdx); } } @@ -44,19 +84,28 @@ export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, return ( <> -
- +
+
+ +
+
+ +
+
+ +
Params
{paramCtrls} diff --git a/src/test/Environment.test.tsx b/src/test/Environment.test.tsx new file mode 100644 index 0000000..f9c3ebb --- /dev/null +++ b/src/test/Environment.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Env } from '../models/Env'; +import { EnvParam } from '../models/EnvParam'; + +describe('Environment Management', () => { + let envs: Env[]; + + beforeEach(() => { + envs = [ + new Env(0, 'DEFAULT', [ + new EnvParam(1, 'host', 'http://localhost'), + new EnvParam(2, 'port', '8080') + ]), + new Env(1, 'env1', [ + new EnvParam(3, 'port', '9090') + ]) + ]; + }); + + describe('add environment', () => { + it('should create new environment with copied params from current', () => { + const currentEnv = envs[1]; // env1 with port=9090 + const newEnvName = 'env2'; + + // Simulate copying params from current env + const newEnv = new Env( + Math.random() * 10000, + newEnvName, + [...currentEnv.params] + ); + envs.push(newEnv); + + expect(envs).toHaveLength(3); + expect(envs[2].name).toBe('env2'); + expect(envs[2].params).toHaveLength(1); + expect(envs[2].params[0].name).toBe('port'); + expect(envs[2].params[0].value).toBe('9090'); + }); + + it('should create new environment with empty params from DEFAULT', () => { + const currentEnv = envs[0]; // DEFAULT + const newEnv = new Env( + Math.random() * 10000, + 'newEnv', + [...currentEnv.params] + ); + envs.push(newEnv); + + expect(envs).toHaveLength(3); + expect(newEnv.params).toHaveLength(2); + expect(newEnv.params.map(p => p.name)).toContain('host'); + expect(newEnv.params.map(p => p.name)).toContain('port'); + }); + }); + + describe('remove environment', () => { + it('should not allow removing DEFAULT environment', () => { + const defaultEnv = envs.find(e => e.name === 'DEFAULT'); + expect(defaultEnv?.isDefault()).toBe(true); + + // In the component, this is blocked by check: if (currEnv.isDefault()) + // Simulating the protection + const canRemove = !defaultEnv?.isDefault(); + expect(canRemove).toBe(false); + }); + + it('should remove non-DEFAULT environment', () => { + const envToRemove = envs[1]; // env1 + const idx = envs.findIndex(x => x.id === envToRemove.id); + + expect(idx).toBe(1); + expect(envs).toHaveLength(2); + + // Simulate removal + envs.splice(idx, 1); + + expect(envs).toHaveLength(1); + expect(envs.find(e => e.id === envToRemove.id)).toBeUndefined(); + }); + + it('should select previous environment after removal', () => { + const idxToRemove = 1; // removing env1 + envs.splice(idxToRemove, 1); + + // After removal, should select max(0, idx-1) = max(0, 0) = 0 + const newSelectedIdx = Math.max(0, idxToRemove - 1); + expect(newSelectedIdx).toBe(0); + expect(envs[newSelectedIdx].name).toBe('DEFAULT'); + }); + }); + + describe('environment selection', () => { + it('should find current env by ID from props', () => { + const selectedId = 1; + const currEnv = envs.find(e => e.id === selectedId) ?? envs[0]; + + expect(currEnv).toBeDefined(); + expect(currEnv.id).toBe(1); + expect(currEnv.name).toBe('env1'); + }); + + it('should fallback to first env if ID not found', () => { + const selectedId = 999; // non-existent + const currEnv = envs.find(e => e.id === selectedId) ?? envs[0]; + + expect(currEnv).toBeDefined(); + expect(currEnv.id).toBe(0); + expect(currEnv.name).toBe('DEFAULT'); + }); + }); +}); diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 0ff54e8..d602025 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ plugins: [react()], test: { globals: true, - environment: "jsdom" + environment: "jsdom", + exclude: ['**/e2e/**', '**/node_modules/**'] } }) \ No newline at end of file From f262c785aa8f8a9ff89f7433c6475cda684db718 Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 14:26:46 +0300 Subject: [PATCH 02/17] =?UTF-8?q?test:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20E2E-=D1=82=D0=B5=D1=81=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BC=D0=BD=D0=BE=D0=B6=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20AppState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/environment.spec.ts | 35 ++++++++++++++++++++++++++++++++++ src/App.tsx | 42 ++++++++++++++++++++++++++++++++--------- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/e2e/environment.spec.ts b/e2e/environment.spec.ts index 630005d..31289fe 100644 --- a/e2e/environment.spec.ts +++ b/e2e/environment.spec.ts @@ -70,4 +70,39 @@ test.describe('Environment Management', () => { await expect(page.locator('#environments')).toBeVisible(); await expect(page.locator('#environments option')).toHaveCount(2); }); + + test('should create multiple environments and switch between them', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + + // Create env1 + page.once('dialog', async dialog => { + await dialog.accept('env1'); + }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + + // Create env2 + page.once('dialog', async dialog => { + await dialog.accept('env2'); + }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + + // Verify we have 3 envs (DEFAULT + env1 + env2) + await expect(page.locator('#environments option')).toHaveCount(3); + + // Switch to each env and verify page doesn't crash + await page.locator('#environments').selectOption({ index: 0 }); + await page.waitForTimeout(200); + await expect(page.locator('#environments')).toBeVisible(); + + await page.locator('#environments').selectOption('env1'); + await page.waitForTimeout(200); + await expect(page.locator('#environments')).toBeVisible(); + + await page.locator('#environments').selectOption('env2'); + await page.waitForTimeout(200); + await expect(page.locator('#environments')).toBeVisible(); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 17a6d07..6dc88a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,17 +18,18 @@ class AppState { static readonly Instance = new AppState(); public loadConfig(cfg: Config) { - this.envs = [...cfg.envs]; this.config = cfg; + this.envs = cfg.envs; // Reference the same array as config } public addEnv(env: Env): number { this.envs.push(env); + // Also update config.envs since they reference the same array + this.config.envs = this.envs; return this.envs.length - 1; } public async saveEnv(env: Env): Promise { - // Create a promise that resolves after 1 second return await new Promise((resolve) => { setTimeout(() => { @@ -41,6 +42,21 @@ class AppState { }, 1000); }); } + + public removeEnv(envId: number) { + const idx = this.envs.findIndex(x => x.id === envId); + if (idx > -1) { + this.envs.splice(idx, 1); + // Also update config.envs since they reference the same array + this.config.envs = this.envs; + } + } + + // Update the internal envs reference to match React state + public syncEnvs(newEnvs: Env[]) { + this.envs = newEnvs; + this.config.envs = newEnvs; + } } function App() { @@ -57,7 +73,12 @@ function App() { let idx = AppState.Instance.envs.findIndex(x => x.id === env.id); if (idx > -1) { AppState.Instance.envs[idx] = env; - setEnvs([...AppState.Instance.envs]); + // Also update config.envs since they reference the same array + AppState.Instance.config.envs = AppState.Instance.envs; + const newEnvs = [...AppState.Instance.envs]; + setEnvs(newEnvs); + AppState.Instance.syncEnvs(newEnvs); // Keep AppState in sync with React state + setConfig(AppState.Instance.config); // Trigger re-render for config } // Then do the async save (for consistency with existing behavior) @@ -70,16 +91,19 @@ function App() { function handleEnvAdded(env: Env): number { const idx = AppState.Instance.addEnv(env); - setEnvs([...AppState.Instance.envs]); + const newEnvs = [...AppState.Instance.envs]; + setEnvs(newEnvs); + AppState.Instance.syncEnvs(newEnvs); // Keep AppState in sync with React state + setConfig(AppState.Instance.config); // Trigger re-render for config return idx; } function handleEnvRemoved(envId: number) { - const idx = AppState.Instance.envs.findIndex(x => x.id === envId); - if (idx > -1) { - AppState.Instance.envs.splice(idx, 1); - setEnvs([...AppState.Instance.envs]); - } + AppState.Instance.removeEnv(envId); + const newEnvs = [...AppState.Instance.envs]; + setEnvs(newEnvs); + AppState.Instance.syncEnvs(newEnvs); // Keep AppState in sync with React state + setConfig(AppState.Instance.config); // Trigger re-render for config } return ( From 6565223b96ff48c961c8ceecf33144a8c92c97b1 Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 15:51:13 +0300 Subject: [PATCH 03/17] fix: env ID generation and React state synchronization --- src/App.tsx | 102 ++++++++++++------------------ src/componets/env/Environment.tsx | 6 +- 2 files changed, 47 insertions(+), 61 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6dc88a8..3b358f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,78 +11,55 @@ import logo from './assets/cgg.png' class AppState { private constructor( public config: Config = new Config(), - public envs: Env[] = [ - ], ) { } static readonly Instance = new AppState(); public loadConfig(cfg: Config) { this.config = cfg; - this.envs = cfg.envs; // Reference the same array as config - } - - public addEnv(env: Env): number { - this.envs.push(env); - // Also update config.envs since they reference the same array - this.config.envs = this.envs; - return this.envs.length - 1; } public async saveEnv(env: Env): Promise { - // Create a promise that resolves after 1 second + // Simulate async save with 1 second delay return await new Promise((resolve) => { setTimeout(() => { - let idx = this.envs.findIndex(x => x.id === env.id); - if (idx > -1) { - this.envs[idx] = env; - console.log("UPDATED envs", this.envs); - } - resolve(idx); // Resolve the promise after updating + console.log("Saved env:", env.name); + resolve(0); }, 1000); }); } - - public removeEnv(envId: number) { - const idx = this.envs.findIndex(x => x.id === envId); - if (idx > -1) { - this.envs.splice(idx, 1); - // Also update config.envs since they reference the same array - this.config.envs = this.envs; - } - } - - // Update the internal envs reference to match React state - public syncEnvs(newEnvs: Env[]) { - this.envs = newEnvs; - this.config.envs = newEnvs; - } } function App() { - const [envs, setEnvs] = useState(AppState.Instance.envs); + const [config, setConfig] = useState(() => AppState.Instance.config); + const [envs, setEnvs] = useState(() => AppState.Instance.config.envs); const [selectedEnv, setSelectedEnv] = useState(0); - const [config, setConfig] = useState(AppState.Instance.config); // Ensure selectedEnv is always valid const validSelectedEnv = Math.min(selectedEnv, Math.max(0, envs.length - 1)); const currentEnv = envs[validSelectedEnv]; async function handleEnvChanged(env: Env) { - // Synchronously update the env in the array - let idx = AppState.Instance.envs.findIndex(x => x.id === env.id); - if (idx > -1) { - AppState.Instance.envs[idx] = env; - // Also update config.envs since they reference the same array - AppState.Instance.config.envs = AppState.Instance.envs; - const newEnvs = [...AppState.Instance.envs]; - setEnvs(newEnvs); - AppState.Instance.syncEnvs(newEnvs); // Keep AppState in sync with React state - setConfig(AppState.Instance.config); // Trigger re-render for config - } + // Optimistic update - update React state immediately + setEnvs(prevEnvs => { + const newEnvs = [...prevEnvs]; + const idx = newEnvs.findIndex(x => x.id === env.id); + if (idx > -1) { + newEnvs[idx] = env; + } + return newEnvs; + }); + + // Also update config.envs to keep it in sync + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = prevConfig.envs.map(e => e.id === env.id ? env : e); + newConfig.template = prevConfig.template; + return newConfig; + }); - // Then do the async save (for consistency with existing behavior) - await AppState.Instance.saveEnv(env); + // Fire off async save in background (no need to wait) + AppState.Instance.saveEnv(env); } function handleEnvSelected(idx: number) { @@ -90,20 +67,25 @@ function App() { } function handleEnvAdded(env: Env): number { - const idx = AppState.Instance.addEnv(env); - const newEnvs = [...AppState.Instance.envs]; - setEnvs(newEnvs); - AppState.Instance.syncEnvs(newEnvs); // Keep AppState in sync with React state - setConfig(AppState.Instance.config); // Trigger re-render for config - return idx; + const newIdx = envs.length; + setEnvs(prevEnvs => [...prevEnvs, env]); + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = [...prevConfig.envs, env]; + newConfig.template = prevConfig.template; + return newConfig; + }); + return newIdx; } function handleEnvRemoved(envId: number) { - AppState.Instance.removeEnv(envId); - const newEnvs = [...AppState.Instance.envs]; - setEnvs(newEnvs); - AppState.Instance.syncEnvs(newEnvs); // Keep AppState in sync with React state - setConfig(AppState.Instance.config); // Trigger re-render for config + setEnvs(prevEnvs => prevEnvs.filter(e => e.id !== envId)); + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = prevConfig.envs.filter(e => e.id !== envId); + newConfig.template = prevConfig.template; + return newConfig; + }); } return ( @@ -112,8 +94,8 @@ function App() {
{ AppState.Instance.loadConfig(x); - setEnvs(AppState.Instance.envs); - setConfig(AppState.Instance.config); + setEnvs(x.envs); + setConfig(x); }} />
{envs.length > 0 ? diff --git a/src/componets/env/Environment.tsx b/src/componets/env/Environment.tsx index 9fd69f7..8da6008 100644 --- a/src/componets/env/Environment.tsx +++ b/src/componets/env/Environment.tsx @@ -44,8 +44,12 @@ export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, const name = prompt("Enter new environment name:"); if (!name || name.trim() === "") return; + // Calculate next integer ID based on max existing ID + const maxId = props.envs.reduce((max, e) => Math.max(max, e.id ?? 0), -1); + const newId = maxId + 1; + const newEnv = new Env( - Math.random() * 10000, + newId, name.trim(), [...currEnv.params] ); From 7ddc8cde84a4886d6c75b7a40dea43307d2da91f Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 15:54:41 +0300 Subject: [PATCH 04/17] feat: prefill template with empty JSON when creating new config --- src/componets/FileChooser.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/componets/FileChooser.tsx b/src/componets/FileChooser.tsx index 616cdf6..9fa74e2 100644 --- a/src/componets/FileChooser.tsx +++ b/src/componets/FileChooser.tsx @@ -18,7 +18,8 @@ export function FileChooser(props: { onSelected: (x: Config) => void }) { function handleNew(){ let cfg = new Config(); - cfg.addEnvs([new Env(0,"DEFAULT", [])]); + cfg.addEnvs([new Env(0, "DEFAULT", [])]); + cfg.addTemplate("{}"); props.onSelected(cfg); } From 7b6800de37f0840318c0de66071bbda8024ef521 Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 16:04:00 +0300 Subject: [PATCH 05/17] feat: auto-update JSON template with environment params --- src/App.tsx | 6 ++++-- src/models/Config.tsx | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3b358f9..eee5c48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,12 +49,14 @@ function App() { } return newEnvs; }); - - // Also update config.envs to keep it in sync + + // Also update config.envs and template to keep them in sync setConfig(prevConfig => { const newConfig = new Config(); 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.updateTemplateFromEnv(env); return newConfig; }); diff --git a/src/models/Config.tsx b/src/models/Config.tsx index ebf0e2e..ad9911b 100644 --- a/src/models/Config.tsx +++ b/src/models/Config.tsx @@ -60,6 +60,36 @@ export class Config { } } + /** + * Updates the template JSON by adding/updating params from the given environment. + * Params are added as "paramName": "paramValue" pairs. + * Existing params in template are preserved if not in the env. + */ + updateTemplateFromEnv(env: Env) { + let templateObj: Record = {}; + + // Try to parse existing template as JSON + try { + if (this.template.content.trim()) { + templateObj = JSON.parse(this.template.content); + } + } catch (e) { + // If parsing fails, start with empty object + console.warn("Template is not valid JSON, starting fresh"); + } + + // Add/update params from the environment + for (const param of env.params) { + if (param.name && param.name.trim() !== "") { + templateObj[param.name] = param.value ?? ""; + } + } + + // Convert back to formatted JSON string + const newTemplateContent = 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)); From bb4a154a27b37dd2620a6de6e3e3ec1efa228ba4 Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 16:06:25 +0300 Subject: [PATCH 06/17] updated gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a547bf3..d63a1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +playwright-report \ No newline at end of file From ddc5311fb9f28f1585d6c6b3d634266a54522a42 Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 16:09:27 +0300 Subject: [PATCH 07/17] delete ignored dir --- playwright-report/index.html | 85 ------------------------------------ 1 file changed, 85 deletions(-) delete mode 100644 playwright-report/index.html diff --git a/playwright-report/index.html b/playwright-report/index.html deleted file mode 100644 index d9c09ad..0000000 --- a/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file From acce21c0e6b71fabc6e5c12c48fcab26e5c05bb9 Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 16:19:41 +0300 Subject: [PATCH 08/17] feat: add params as JSON placeholders with !!! prefix --- src/models/Config.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/models/Config.tsx b/src/models/Config.tsx index ad9911b..2f5f2eb 100644 --- a/src/models/Config.tsx +++ b/src/models/Config.tsx @@ -62,8 +62,8 @@ export class Config { /** * Updates the template JSON by adding/updating params from the given environment. - * Params are added as "paramName": "paramValue" pairs. - * Existing params in template are preserved if not in the env. + * Params are added as "!!! paramName": "@paramName@" placeholder pairs. + * Existing template content is preserved. */ updateTemplateFromEnv(env: Env) { let templateObj: Record = {}; @@ -78,10 +78,12 @@ export class Config { console.warn("Template is not valid JSON, starting fresh"); } - // Add/update params from the environment + // Add/update params from the environment as placeholders for (const param of env.params) { if (param.name && param.name.trim() !== "") { - templateObj[param.name] = param.value ?? ""; + const placeholderKey = `!!! ${param.name}`; + const placeholderValue = `@${param.name}@`; + templateObj[placeholderKey] = placeholderValue; } } From 15f890abdafed348682442b6c02a5f38ee880041 Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 17:09:32 +0300 Subject: [PATCH 09/17] feat: add manual template editor with view/edit modes --- src/App.tsx | 11 ++- src/componets/content/ConfigTemplate.tsx | 118 +++++++++++++++++++++++ src/componets/content/Content.tsx | 17 +--- 3 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 src/componets/content/ConfigTemplate.tsx diff --git a/src/App.tsx b/src/App.tsx index eee5c48..4d7ec74 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -90,6 +90,15 @@ function App() { }); } + function handleTemplateSaved(newContent: string) { + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = prevConfig.envs; + newConfig.addTemplate(newContent); + return newConfig; + }); + } + return ( <>
@@ -111,7 +120,7 @@ function App() { onRemove={handleEnvRemoved} />
- +
) : diff --git a/src/componets/content/ConfigTemplate.tsx b/src/componets/content/ConfigTemplate.tsx new file mode 100644 index 0000000..d0a8249 --- /dev/null +++ b/src/componets/content/ConfigTemplate.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import Highlight from 'react-highlight'; +import 'highlight.js/styles/far.css'; +import { Config } from "../../models/Config"; + +interface ConfigTemplateProps { + config: Config; + onSaved: (newContent: string) => void; +} + +export function ConfigTemplate(props: ConfigTemplateProps) { + const [mode, setMode] = useState<'view' | 'edit'>('view'); + const [draftContent, setDraftContent] = useState(props.config.template.content); + const [originalContent, setOriginalContent] = useState(props.config.template.content); + const [jsonError, setJsonError] = useState(null); + + // Sync draft when config changes (in view mode) + if (mode === 'view') { + setDraftContent(props.config.template.content); + } + + function handleEdit() { + setOriginalContent(props.config.template.content); + setDraftContent(props.config.template.content); + setJsonError(null); + setMode('edit'); + } + + function handleRevert() { + setDraftContent(originalContent); + setJsonError(null); + setMode('view'); + } + + function handleSave() { + // Validate JSON before saving + try { + JSON.parse(draftContent); + setJsonError(null); + props.onSaved(draftContent); + setMode('view'); + } catch (e) { + setJsonError((e as Error).message); + } + } + + function handleDraftChange(value: string) { + setDraftContent(value); + // Validate JSON on every change + try { + if (value.trim()) { + JSON.parse(value); + setJsonError(null); + } else { + setJsonError(null); + } + } catch (e) { + setJsonError((e as Error).message); + } + } + + const isValidJson = jsonError === null; + + return ( +
+ {mode === 'view' ? ( + <> +
+ +
+ + {props.config.template.content || "{}"} + + + ) : ( + <> +
+ + + + {isValidJson ? 'Valid JSON' : 'Invalid JSON'} + +
+ {jsonError && ( +
+ {jsonError} +
+ )} +