From 28fc3f9e21118812a9cecd04b2af5071a87a0c80 Mon Sep 17 00:00:00 2001 From: sokol Date: Fri, 20 Feb 2026 16:55:34 +0300 Subject: [PATCH 1/4] fix: improved JSON validation for comments and placeholders --- src/componets/content/ConfigTemplate.tsx | 58 ++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/src/componets/content/ConfigTemplate.tsx b/src/componets/content/ConfigTemplate.tsx index 4324ea3..262b04d 100644 --- a/src/componets/content/ConfigTemplate.tsx +++ b/src/componets/content/ConfigTemplate.tsx @@ -40,14 +40,56 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr setJsonError(null); return true; } - - // Strip comments (// single-line and /* */ multi-line) and placeholders - const sanitizedValue = value - .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments - .replace(/\/\/.*$/gm, '') // Remove // comments - .replace(/@[^@]+@/g, '1'); // Replace placeholders - - JSON.parse(sanitizedValue); + + // 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); return true; } catch (e) { From 1beb9f2026abf43810df20c8d84cc590b1f17164 Mon Sep 17 00:00:00 2001 From: sokol Date: Fri, 20 Feb 2026 17:09:34 +0300 Subject: [PATCH 2/4] fix: preserve existing template when adding parameters --- src/models/Config.tsx | 29 ++++- src/test/ConfigUpdate.test.ts | 159 +++++++++++++++++++++++++ src/test/JsonValidation.test.ts | 204 ++++++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 src/test/ConfigUpdate.test.ts create mode 100644 src/test/JsonValidation.test.ts diff --git a/src/models/Config.tsx b/src/models/Config.tsx index 407c824..adbc5b5 100644 --- a/src/models/Config.tsx +++ b/src/models/Config.tsx @@ -97,29 +97,48 @@ export class Config { * Updates template by adding placeholders for environment params */ public updateTemplateFromEnv(env: Env): void { - let templateObj: Record = {}; + // 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 = {}; + let hasExistingContent = false; + try { if (this.template.content.trim()) { templateObj = JSON.parse(this.template.content); + hasExistingContent = Object.keys(templateObj).length > 0; } } catch { - // Start fresh if invalid JSON + // If invalid JSON, preserve the raw content and don't modify + return; } // Add placeholders for params that don't exist yet + let hasChanges = false; for (const param of env.params) { if (param.name && param.name.trim()) { - const placeholder = `@${param.name}@`; + const paramName = param.name.trim(); + const placeholder = `@${paramName}@`; + const templateKey = `!!! ${paramName}`; + // Check if placeholder exists anywhere in template if (!this.template.content.includes(placeholder)) { - templateObj[`!!! ${param.name}`] = placeholder; + // Only add if not already in templateObj + if (!templateObj[templateKey]) { + templateObj[templateKey] = placeholder; + hasChanges = true; + } } } } - this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4)); + // Only update if there are actual changes + if (hasChanges || !hasExistingContent) { + this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4)); + } } /** diff --git a/src/test/ConfigUpdate.test.ts b/src/test/ConfigUpdate.test.ts new file mode 100644 index 0000000..80a3463 --- /dev/null +++ b/src/test/ConfigUpdate.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { Config } from '../models/Config'; +import { Env } from '../models/Env'; +import { EnvParam } from '../models/EnvParam'; + +describe('Config Template Update', () => { + it('should preserve existing template when adding new parameter', () => { + // Arrange: Create config with existing template + const config = new Config(); + const existingTemplate = `{ + "existingKey": "existingValue", + "anotherKey": 123 +}`; + config.setTemplate(existingTemplate); + + const env = new Env(0, 'DEFAULT', [ + new EnvParam(1, 'host', 'localhost'), + new EnvParam(2, 'port', '8080') + ]); + config.setEnvs([env]); + + // Act: Update template from env (simulating adding parameters) + config.updateTemplateFromEnv(env); + + // Assert: Template should have both existing content and new placeholders + const templateContent = config.template.content; + const templateObj = JSON.parse(templateContent); + + expect(templateObj.existingKey).toBe('existingValue'); + expect(templateObj.anotherKey).toBe(123); + expect(templateObj['!!! host']).toBe('@host@'); + expect(templateObj['!!! port']).toBe('@port@'); + }); + + it('should not duplicate placeholders that already exist', () => { + // Arrange: Create config with template that already has placeholder + const config = new Config(); + const existingTemplate = `{ + "host": "@host@", + "port": 8080 +}`; + config.setTemplate(existingTemplate); + + const env = new Env(0, 'DEFAULT', [ + new EnvParam(1, 'host', 'localhost'), // Already in template + new EnvParam(2, 'port', '8080') // Already in template + ]); + config.setEnvs([env]); + + // Act: Update template + config.updateTemplateFromEnv(env); + + // Assert: Should not add !!! entries for existing placeholders + const templateContent = config.template.content; + // Count occurrences of @host@ + const hostCount = (templateContent.match(/@host@/g) || []).length; + expect(hostCount).toBe(1); // Only original, no duplicate + + // Should preserve original structure + expect(templateContent).toContain('"host": "@host@"'); + }); + + it('should initialize empty template', () => { + // Arrange: Create config with empty template + const config = new Config(); + config.setTemplate(''); + + const env = new Env(0, 'DEFAULT', [ + new EnvParam(1, 'host', 'localhost') + ]); + config.setEnvs([env]); + + // Act: Update template + config.updateTemplateFromEnv(env); + + // Assert: Should create template with placeholder + const templateContent = config.template.content; + const templateObj = JSON.parse(templateContent); + expect(templateObj['!!! host']).toBe('@host@'); + }); + + it('should not modify invalid JSON template', () => { + // Arrange: Create config with invalid JSON template + const config = new Config(); + const invalidTemplate = `{ + "key": "value" // missing comma + "another": "key" +}`; + config.setTemplate(invalidTemplate); + const originalTemplate = config.template.content; + + const env = new Env(0, 'DEFAULT', [ + new EnvParam(1, 'host', 'localhost') + ]); + config.setEnvs([env]); + + // Act: Update template (should not modify invalid JSON) + config.updateTemplateFromEnv(env); + + // Assert: Template should remain unchanged + expect(config.template.content).toBe(originalTemplate); + }); + + it('should add multiple new placeholders to existing template', () => { + // Arrange + const config = new Config(); + const existingTemplate = `{ + "name": "myapp" +}`; + config.setTemplate(existingTemplate); + + const env = new Env(0, 'DEFAULT', [ + new EnvParam(1, 'host', 'localhost'), + new EnvParam(2, 'port', '8080'), + new EnvParam(3, 'user', 'admin') + ]); + config.setEnvs([env]); + + // Act + config.updateTemplateFromEnv(env); + + // Assert + const templateObj = JSON.parse(config.template.content); + expect(templateObj.name).toBe('myapp'); + expect(templateObj['!!! host']).toBe('@host@'); + expect(templateObj['!!! port']).toBe('@port@'); + expect(templateObj['!!! user']).toBe('@user@'); + }); + + it('should only add placeholders for params not in template', () => { + // Arrange: Template has @host@ but not @port@ + const config = new Config(); + config.setTemplate(`{ + "host": "@host@", + "name": "test" +}`); + + const env = new Env(0, 'DEFAULT', [ + new EnvParam(1, 'host', 'localhost'), // In template + new EnvParam(2, 'port', '8080') // Not in template + ]); + config.setEnvs([env]); + + // Act + config.updateTemplateFromEnv(env); + + // Assert + const templateContent = config.template.content; + const templateObj = JSON.parse(templateContent); + + // Should preserve original + expect(templateObj.host).toBe('@host@'); + expect(templateObj.name).toBe('test'); + + // Should add only missing + expect(templateObj['!!! port']).toBe('@port@'); + expect(templateObj['!!! host']).toBeUndefined(); // Not added, already exists + }); +}); diff --git a/src/test/JsonValidation.test.ts b/src/test/JsonValidation.test.ts new file mode 100644 index 0000000..d6a5486 --- /dev/null +++ b/src/test/JsonValidation.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Helper function that mimics the validation logic in ConfigTemplate.tsx + * This is the improved version with comment support + */ +function validateJsonWithComments(value: string): { valid: boolean; error?: string } { + try { + if (!value.trim()) { + return { valid: true }; + } + + // Step 1: Remove /* */ multi-line comments + let sanitized = value.replace(/\/\*[\s\S]*?\*\//g, ''); + + // Step 2: Process line by line to handle // comments outside quotes + const lines = sanitized.split('\n'); + const processedLines = lines.map(line => { + let result = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const prevChar = i > 0 ? line[i - 1] : ''; + + // Check for quote start/end (not escaped) + if ((char === '"' || char === "'") && prevChar !== '\\') { + if (!inQuote) { + inQuote = true; + quoteChar = char; + } else if (char === quoteChar) { + inQuote = false; + quoteChar = ''; + } + result += char; + } + // Check for // comment start (only outside quotes) + else if (!inQuote && char === '/' && line[i + 1] === '/') { + break; // Rest of line is comment + } + else { + result += char; + } + } + + return result; + }); + + sanitized = processedLines.join('\n'); + + // Step 3: Replace @placeholders@ with dummy values + sanitized = sanitized.replace(/@[^@]+@/g, '1'); + + JSON.parse(sanitized); + return { valid: true }; + } catch (e) { + return { valid: false, error: (e as Error).message }; + } +} + +describe('JSON Validation - Comment Support', () => { + it('should remove single-line // comments outside quotes', () => { + const json = `{ + "key": "value" // this is a comment + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should preserve // inside quoted strings', () => { + const json = `{ + "url": "http://example.com/path", + "description": "This // that // other" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should preserve URLs with // in values', () => { + const json = `{ + "apiUrl": "http://localhost:8080/api", + "cdn": "https://cdn.example.com/assets" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should remove multi-line /* */ comments', () => { + const json = `{ + /* This is a + multi-line comment */ + "key": "value" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should handle mixed comments and placeholders', () => { + const json = `{ + // Host configuration + "host": "@host@", + "port": @port@, // unquoted placeholder + /* API settings */ + "apiUrl": "http://@host@:@port@/api" // URL with placeholders + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should handle comment-only lines', () => { + const json = `{ + // Just a comment + "key": "value" + // Another comment + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should preserve // in string values with special characters', () => { + const json = `{ + "regex": "pattern // with slashes", + "path": "C://Program Files//App", + "comment": "text // more text // even more" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should handle escaped quotes correctly', () => { + const json = `{ + "message": "He said \\"hello // world\\"" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should reject invalid JSON with comments', () => { + const json = `{ + "key": "value" // missing comma + "another": "key" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(false); + expect(result.error).toContain('Expected'); + }); + + it('should handle empty template', () => { + const result = validateJsonWithComments(''); + expect(result.valid).toBe(true); + }); + + it('should handle whitespace only', () => { + const result = validateJsonWithComments(' \n\t '); + expect(result.valid).toBe(true); + }); +}); + +describe('JSON Validation - Placeholder Support', () => { + it('should validate quoted placeholders', () => { + const json = `{ + "Host": "@host@", + "Port": "@port@" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should validate unquoted placeholders', () => { + const json = `{ + "Host": "@host@", + "Port": @port@ + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should validate placeholders in URLs', () => { + const json = `{ + "ApiUrl": "http://@host@:@port@/api/v1" + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); + + it('should validate complex template with comments and placeholders', () => { + const json = `{ + // Database config + "db": { + "host": "@db_host@", + "port": @db_port@, + "url": "jdbc:mysql://@db_host@:@db_port@/mydb" // connection string + }, + /* API settings */ + "api": { + "baseUrl": "https://@api_host@/v1", + "timeout": @timeout@ + } + }`; + const result = validateJsonWithComments(json); + expect(result.valid).toBe(true); + }); +}); From 70e0545fef198fe994a7dbfa1e0fe49fe6dd5224 Mon Sep 17 00:00:00 2001 From: sokol Date: Fri, 20 Feb 2026 17:17:11 +0300 Subject: [PATCH 3/4] feat: show validation warning description inline --- src/componets/content/Content.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/componets/content/Content.tsx b/src/componets/content/Content.tsx index 731ad90..b0d3703 100644 --- a/src/componets/content/Content.tsx +++ b/src/componets/content/Content.tsx @@ -17,11 +17,14 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) { // Validate placeholders for warning badge const missingPlaceholders = config.validatePlaceholders(); const hasValidationWarnings = missingPlaceholders.length > 0; + const warningMessage = hasValidationWarnings + ? `Missing params: ${missingPlaceholders.join(', ')}` + : ''; const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [ { id: 'env', label: 'Env' }, - { - id: 'template', + { + id: 'template', label: 'Content Template', badge: hasValidationWarnings ? '!' : undefined, badgeVariant: hasValidationWarnings ? 'warning' : undefined, @@ -33,7 +36,15 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) { return (
- +
+ + {activeTab === 'template' && hasValidationWarnings && ( +
+ Warning: + {warningMessage} +
+ )} +
From ecded86467e4b5051f0b8d61c7725be3b2fbcbdc Mon Sep 17 00:00:00 2001 From: sokol Date: Fri, 20 Feb 2026 17:28:37 +0300 Subject: [PATCH 4/4] feat: show validation warning next to View Mode / Valid JSON badge --- src/componets/content/ConfigTemplate.tsx | 28 ++++++++++++++++++++---- src/componets/content/Content.tsx | 13 +---------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/componets/content/ConfigTemplate.tsx b/src/componets/content/ConfigTemplate.tsx index 262b04d..3ced838 100644 --- a/src/componets/content/ConfigTemplate.tsx +++ b/src/componets/content/ConfigTemplate.tsx @@ -14,6 +14,13 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr const [originalContent, setOriginalContent] = useState(config.template.content); const [jsonError, setJsonError] = useState(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) useEffect(() => { if (mode === 'view') { @@ -137,9 +144,15 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
{mode === 'view' ? (
-
-
+
+
View Mode + {hasValidationWarnings && ( +
+ Warning: + {warningMessage} +
+ )}
- +
) : ( @@ -165,7 +178,7 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr > Save Changes - +
{jsonError && ( diff --git a/src/componets/content/Content.tsx b/src/componets/content/Content.tsx index b0d3703..0f13bd4 100644 --- a/src/componets/content/Content.tsx +++ b/src/componets/content/Content.tsx @@ -17,9 +17,6 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) { // Validate placeholders for warning badge const missingPlaceholders = config.validatePlaceholders(); const hasValidationWarnings = missingPlaceholders.length > 0; - const warningMessage = hasValidationWarnings - ? `Missing params: ${missingPlaceholders.join(', ')}` - : ''; const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [ { id: 'env', label: 'Env' }, @@ -36,15 +33,7 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) { return (
-
- - {activeTab === 'template' && hasValidationWarnings && ( -
- Warning: - {warningMessage} -
- )} -
+