Added custom json edit to config tab

This commit is contained in:
Richard Osborne
2026-01-08 13:27:38 +01:00
parent 4a1080d547
commit 67b8ddc9c3
53 changed files with 8756 additions and 210 deletions

View File

@@ -0,0 +1,149 @@
/**
* JSONEditor Main Component Styles
* Uses design tokens for consistency with OpenNoodl design system
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
overflow: hidden;
}
/* Header */
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
}
.Title {
font-size: 13px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
/* Mode Toggle */
.ModeToggle {
display: flex;
gap: 4px;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
padding: 2px;
button {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: none;
background: transparent;
color: var(--theme-color-fg-default);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-3);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.Active {
background-color: var(--theme-color-primary);
color: white;
font-weight: 600;
&:hover {
background-color: var(--theme-color-primary);
}
}
}
}
/* Validation Error Banner */
.ValidationError {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background-color: #fef2f2;
border-bottom: 1px solid #fecaca;
}
.ErrorIcon {
font-size: 20px;
flex-shrink: 0;
line-height: 1;
}
.ErrorContent {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.ErrorMessage {
font-size: 13px;
font-weight: 600;
color: #dc2626;
line-height: 1.4;
}
.ErrorSuggestion {
font-size: 12px;
color: #7c2d12;
line-height: 1.4;
padding: 8px 12px;
background-color: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
margin-top: 4px;
}
/* Editor Content */
.EditorContent {
flex: 1;
overflow: hidden;
position: relative;
}
/* Advanced Mode Placeholder (temporary) */
.AdvancedModePlaceholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px 24px;
background-color: var(--theme-color-bg-2);
text-align: center;
}
.PlaceholderIcon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.PlaceholderText {
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-default);
margin-bottom: 8px;
}
.PlaceholderDetail {
font-size: 13px;
color: var(--theme-color-fg-muted);
}

View File

@@ -0,0 +1,158 @@
/**
* JSONEditor Component
*
* A modern, dual-mode JSON editor with:
* - Easy Mode: Visual tree builder (no-code friendly)
* - Advanced Mode: Text editor with validation
*
* @module json-editor
*/
import React, { useState, useEffect, useMemo } from 'react';
import css from './JSONEditor.module.scss';
import { AdvancedMode } from './modes/AdvancedMode/AdvancedMode';
import { EasyMode } from './modes/EasyMode/EasyMode';
import { validateJSON } from './utils/jsonValidator';
import { valueToTreeNode, treeNodeToValue } from './utils/treeConverter';
import { JSONEditorProps, EditorMode, JSONTreeNode } from './utils/types';
/**
* Main JSONEditor Component
*/
export function JSONEditor({
value,
onChange,
onSave,
defaultMode = 'easy',
mode: forcedMode,
expectedType = 'any',
disabled = false,
height,
placeholder
}: JSONEditorProps) {
// Current editor mode
const [currentMode, setCurrentMode] = useState<EditorMode>(forcedMode || defaultMode);
// Internal JSON string state
const [jsonString, setJsonString] = useState<string>(() => {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return expectedType === 'array' ? '[]' : '{}';
}
});
// Sync with external value changes
useEffect(() => {
if (typeof value === 'string') {
setJsonString(value);
} else {
try {
setJsonString(JSON.stringify(value, null, 2));
} catch {
// Invalid value, keep current
}
}
}, [value]);
// Parse JSON to tree node for Easy Mode
const treeNode: JSONTreeNode | null = useMemo(() => {
try {
const parsed = JSON.parse(jsonString || (expectedType === 'array' ? '[]' : '{}'));
return valueToTreeNode(parsed);
} catch {
// Invalid JSON - return empty structure
return valueToTreeNode(expectedType === 'array' ? [] : {});
}
}, [jsonString, expectedType]);
// Validate current JSON
const validation = useMemo(() => {
return validateJSON(jsonString, expectedType);
}, [jsonString, expectedType]);
// Handle mode toggle
const handleModeChange = (newMode: EditorMode) => {
if (forcedMode) return; // Can't change if forced
setCurrentMode(newMode);
// Save preference to localStorage
try {
localStorage.setItem('json-editor-preferred-mode', newMode);
} catch {
// Ignore storage errors
}
};
// Handle tree changes from Easy Mode
const handleTreeChange = (newNode: JSONTreeNode) => {
try {
const newValue = treeNodeToValue(newNode);
const newJson = JSON.stringify(newValue, null, 2);
setJsonString(newJson);
onChange(newJson);
} catch (error) {
console.error('Failed to convert tree to JSON:', error);
}
};
// Handle text changes from Advanced Mode
const handleTextChange = (newText: string) => {
setJsonString(newText);
onChange(newText);
};
// Container style
const containerStyle: React.CSSProperties = {
height: height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : '400px'
};
return (
<div className={css['Root']} style={containerStyle}>
{/* Header with mode toggle */}
<div className={css['Header']}>
<div className={css['Title']}>JSON Editor</div>
{!forcedMode && (
<div className={css['ModeToggle']}>
<button
className={currentMode === 'easy' ? css['Active'] : ''}
onClick={() => handleModeChange('easy')}
disabled={disabled}
>
Easy
</button>
<button
className={currentMode === 'advanced' ? css['Active'] : ''}
onClick={() => handleModeChange('advanced')}
disabled={disabled}
>
Advanced
</button>
</div>
)}
</div>
{/* Validation Status */}
{!validation.valid && (
<div className={css['ValidationError']}>
<div className={css['ErrorIcon']}></div>
<div className={css['ErrorContent']}>
<div className={css['ErrorMessage']}>{validation.error}</div>
{validation.suggestion && <div className={css['ErrorSuggestion']}>💡 {validation.suggestion}</div>}
</div>
</div>
)}
{/* Editor Content */}
<div className={css['EditorContent']}>
{currentMode === 'easy' ? (
<EasyMode rootNode={treeNode!} onChange={handleTreeChange} disabled={disabled} />
) : (
<AdvancedMode value={jsonString} onChange={handleTextChange} disabled={disabled} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
/**
* JSON Editor Component
*
* Public exports for the JSON Editor component.
*
* @module json-editor
*/
export { JSONEditor } from './JSONEditor';
export type { JSONEditorProps, EditorMode, JSONValueType, ValidationResult } from './utils/types';
export { validateJSON, formatJSON, isValidJSON } from './utils/jsonValidator';

View File

@@ -0,0 +1,156 @@
/**
* Advanced Mode Styles
* Text editor for power users
*/
.Root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
overflow: hidden;
}
/* Toolbar */
.Toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.ToolbarLeft,
.ToolbarRight {
display: flex;
align-items: center;
gap: 12px;
}
.StatusValid {
font-size: 13px;
font-weight: 500;
color: #10b981;
}
.StatusInvalid {
font-size: 13px;
font-weight: 500;
color: #ef4444;
}
.FormatButton {
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
background-color: var(--theme-color-primary);
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
/* Text Editor */
.Editor {
flex: 1;
padding: 12px;
font-size: 13px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
line-height: 1.6;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
border: none;
outline: none;
resize: none;
overflow-y: auto;
&::placeholder {
color: var(--theme-color-fg-muted);
opacity: 0.5;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* Error Panel */
.ErrorPanel {
padding: 12px;
background-color: #ef44441a;
border-top: 2px solid #ef4444;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ErrorHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ErrorIcon {
font-size: 16px;
}
.ErrorTitle {
font-size: 14px;
font-weight: 600;
color: #ef4444;
}
.ErrorMessage {
font-size: 13px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
color: var(--theme-color-fg-default);
margin-bottom: 8px;
padding: 8px;
background-color: var(--theme-color-bg-2);
border-radius: 3px;
border-left: 3px solid #ef4444;
}
.ErrorSuggestion {
font-size: 13px;
color: var(--theme-color-fg-default);
padding: 8px;
background-color: #3b82f61a;
border-radius: 3px;
border-left: 3px solid #3b82f6;
margin-bottom: 8px;
strong {
color: #3b82f6;
}
}
.ErrorLocation {
font-size: 12px;
font-weight: 500;
color: var(--theme-color-fg-muted);
}

View File

@@ -0,0 +1,122 @@
/**
* Advanced Mode - Text Editor for Power Users
*
* A text-based JSON editor with syntax highlighting, validation,
* and helpful error messages. For users who prefer direct editing.
*
* @module json-editor/modes
*/
import React, { useState, useEffect } from 'react';
import { validateJSON } from '../../utils/jsonValidator';
import css from './AdvancedMode.module.scss';
export interface AdvancedModeProps {
/** Current JSON string value */
value: string;
/** Called when value changes */
onChange?: (value: string) => void;
/** Readonly mode */
disabled?: boolean;
}
/**
* Advanced text editor mode
*/
export function AdvancedMode({ value, onChange, disabled }: AdvancedModeProps) {
const [localValue, setLocalValue] = useState(value);
const [validationResult, setValidationResult] = useState(validateJSON(value));
// Sync with external value changes
useEffect(() => {
setLocalValue(value);
setValidationResult(validateJSON(value));
}, [value]);
// Handle text changes
const handleChange = (newValue: string) => {
setLocalValue(newValue);
const result = validateJSON(newValue);
setValidationResult(result);
// Only propagate valid JSON changes
if (result.valid && onChange) {
onChange(newValue);
}
};
// Format/pretty-print JSON
const handleFormat = () => {
if (validationResult.valid) {
try {
const parsed = JSON.parse(localValue);
const formatted = JSON.stringify(parsed, null, 2);
setLocalValue(formatted);
if (onChange) {
onChange(formatted);
}
} catch (e) {
// Should not happen if validation passed
console.error('Format error:', e);
}
}
};
return (
<div className={css['Root']}>
{/* Toolbar */}
<div className={css['Toolbar']}>
<div className={css['ToolbarLeft']}>
{validationResult.valid ? (
<span className={css['StatusValid']}> Valid JSON</span>
) : (
<span className={css['StatusInvalid']}> Invalid JSON</span>
)}
</div>
<div className={css['ToolbarRight']}>
<button
onClick={handleFormat}
disabled={!validationResult.valid || disabled}
className={css['FormatButton']}
title="Format JSON (Ctrl+Shift+F)"
>
Format
</button>
</div>
</div>
{/* Text Editor */}
<textarea
value={localValue}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
className={css['Editor']}
placeholder='{\n "key": "value"\n}'
spellCheck={false}
/>
{/* Validation Errors */}
{!validationResult.valid && (
<div className={css['ErrorPanel']}>
<div className={css['ErrorHeader']}>
<span className={css['ErrorIcon']}></span>
<span className={css['ErrorTitle']}>JSON Syntax Error</span>
</div>
<div className={css['ErrorMessage']}>{validationResult.error}</div>
{validationResult.suggestion && (
<div className={css['ErrorSuggestion']}>
<strong>💡 Suggestion:</strong> {validationResult.suggestion}
</div>
)}
{validationResult.line !== undefined && (
<div className={css['ErrorLocation']}>
Line {validationResult.line}
{validationResult.column !== undefined && `, Column ${validationResult.column}`}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,283 @@
/**
* Easy Mode Styles
* Uses design tokens for consistency with OpenNoodl design system
*/
.Root {
width: 100%;
height: 100%;
overflow-y: auto;
padding: 12px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
}
/* Empty State */
.EmptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 150px;
padding: 24px;
text-align: center;
}
.EmptyIcon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.EmptyText {
font-size: 13px;
color: var(--theme-color-fg-muted);
}
/* Tree Node */
.TreeNode {
margin-bottom: 4px;
}
.NodeHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 3px;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-3);
}
}
/* Node Labels */
.Key {
color: var(--theme-color-primary);
font-weight: 500;
}
.Index {
color: var(--theme-color-fg-muted);
font-weight: 500;
}
.Colon {
color: var(--theme-color-fg-muted);
}
/* Type Badge */
.TypeBadge {
display: inline-block;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
border-radius: 3px;
letter-spacing: 0.5px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-muted);
border: 1px solid var(--theme-color-border-default);
&[data-type='string'] {
background-color: #3b82f61a;
color: #3b82f6;
border-color: #3b82f64d;
}
&[data-type='number'] {
background-color: #10b9811a;
color: #10b981;
border-color: #10b9814d;
}
&[data-type='boolean'] {
background-color: #f59e0b1a;
color: #f59e0b;
border-color: #f59e0b4d;
}
&[data-type='null'] {
background-color: #6b72801a;
color: #6b7280;
border-color: #6b72804d;
}
&[data-type='array'] {
background-color: #8b5cf61a;
color: #8b5cf6;
border-color: #8b5cf64d;
}
&[data-type='object'] {
background-color: #ec48991a;
color: #ec4899;
border-color: #ec48994d;
}
}
/* Value Display */
.Value {
color: var(--theme-color-fg-default);
font-family: inherit;
&[data-type='string'] {
color: #3b82f6;
}
&[data-type='number'] {
color: #10b981;
}
&[data-type='boolean'] {
color: #f59e0b;
}
&[data-type='null'] {
color: #6b7280;
font-style: italic;
}
}
/* Count Badge */
.Count {
font-size: 11px;
color: var(--theme-color-fg-muted);
font-weight: 400;
}
/* Children Container */
.Children {
margin-left: 0;
border-left: 1px solid var(--theme-color-border-default);
padding-left: 8px;
margin-top: 2px;
}
/* Action Buttons */
.EditButton,
.AddButton,
.DeleteButton {
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
margin-left: 6px;
}
.EditButton {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-primary);
&:hover {
background-color: var(--theme-color-primary);
color: white;
}
}
.AddButton {
background-color: var(--theme-color-primary);
color: white;
font-size: 11px;
&:hover {
opacity: 0.9;
}
}
.DeleteButton {
background-color: #dc2626;
color: white;
font-size: 14px;
font-weight: bold;
line-height: 1;
&:hover {
background-color: #b91c1c;
}
}
/* Add Item Form */
.AddItemForm {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
margin: 4px 0;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
border: 1px solid var(--theme-color-border-default);
input[type='text'] {
flex: 1;
padding: 4px 8px;
font-size: 13px;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
&::placeholder {
color: var(--theme-color-fg-muted);
}
}
select {
padding: 4px 8px;
font-size: 13px;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
cursor: pointer;
}
button {
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
&:first-of-type {
background-color: var(--theme-color-primary);
color: white;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&:last-of-type {
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
&:hover {
background-color: var(--theme-color-bg-1);
}
}
}
}
/* Clickable value for editing */
.Value {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,356 @@
/**
* Easy Mode - Visual JSON Tree Builder (WITH EDITING)
*
* A visual tree-based editor where users can't break JSON structure.
* Perfect for no-coders who don't understand JSON syntax.
*
* @module json-editor/modes
*/
import React, { useState } from 'react';
import { setValueAtPath, deleteValueAtPath, treeNodeToValue, valueToTreeNode } from '../../utils/treeConverter';
import { JSONTreeNode, JSONValueType } from '../../utils/types';
import css from './EasyMode.module.scss';
import { ValueEditor } from './ValueEditor';
export interface EasyModeProps {
rootNode: JSONTreeNode;
onChange?: (node: JSONTreeNode) => void;
disabled?: boolean;
}
interface TreeNodeProps {
node: JSONTreeNode;
depth: number;
onEdit: (path: (string | number)[], value: unknown) => void;
onDelete: (path: (string | number)[]) => void;
onAdd: (path: (string | number)[], type: JSONValueType, key?: string) => void;
disabled?: boolean;
}
/**
* Editable Tree Node Component
*/
function EditableTreeNode({ node, depth, onEdit, onDelete, onAdd, disabled }: TreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const [isAddingItem, setIsAddingItem] = useState(false);
const [newItemType, setNewItemType] = useState<JSONValueType>('string');
const [newKey, setNewKey] = useState('');
const hasChildren = node.children && node.children.length > 0;
const indent = depth * 20;
const canEdit =
!disabled && (node.type === 'string' || node.type === 'number' || node.type === 'boolean' || node.type === 'null');
const canDelete = !disabled && depth > 0; // Can't delete root
// Type badge
const typeBadge = (
<span className={css['TypeBadge']} data-type={node.type}>
{node.type}
</span>
);
// Handle saving edited value
const handleSaveEdit = (value: unknown) => {
onEdit(node.path, value);
setIsEditing(false);
};
// Handle adding new item/property
const handleAddItem = () => {
if (node.type === 'array') {
onAdd(node.path, newItemType);
} else if (node.type === 'object') {
if (!newKey.trim()) return;
onAdd(node.path, newItemType, newKey.trim());
}
setIsAddingItem(false);
setNewKey('');
setNewItemType('string');
};
// Arrays
if (node.type === 'array') {
return (
<div className={css['TreeNode']} style={{ marginLeft: `${indent}px` }}>
<div className={css['NodeHeader']}>
{node.key && (
<>
<span className={css['Key']}>{node.key}</span>
<span className={css['Colon']}>:</span>
</>
)}
{node.index !== undefined && <span className={css['Index']}>[{node.index}]</span>}
{typeBadge}
<span className={css['Count']}>({node.children?.length || 0} items)</span>
{!disabled && (
<button onClick={() => setIsAddingItem(!isAddingItem)} className={css['AddButton']} title="Add item">
+ Add Item
</button>
)}
{canDelete && (
<button onClick={() => onDelete(node.path)} className={css['DeleteButton']} title="Delete">
</button>
)}
</div>
{isAddingItem && (
<div className={css['AddItemForm']}>
<select value={newItemType} onChange={(e) => setNewItemType(e.target.value as JSONValueType)}>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="null">Null</option>
<option value="array">Array</option>
<option value="object">Object</option>
</select>
<button onClick={handleAddItem}>Add</button>
<button onClick={() => setIsAddingItem(false)}>Cancel</button>
</div>
)}
{hasChildren && (
<div className={css['Children']}>
{node.children!.map((child, idx) => (
<EditableTreeNode
key={child.id || idx}
node={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAdd={onAdd}
disabled={disabled}
/>
))}
</div>
)}
</div>
);
}
// Objects
if (node.type === 'object') {
return (
<div className={css['TreeNode']} style={{ marginLeft: `${indent}px` }}>
<div className={css['NodeHeader']}>
{node.key && (
<>
<span className={css['Key']}>{node.key}</span>
<span className={css['Colon']}>:</span>
</>
)}
{node.index !== undefined && <span className={css['Index']}>[{node.index}]</span>}
{typeBadge}
<span className={css['Count']}>({node.children?.length || 0} properties)</span>
{!disabled && (
<button onClick={() => setIsAddingItem(!isAddingItem)} className={css['AddButton']} title="Add property">
+ Add Property
</button>
)}
{canDelete && (
<button onClick={() => onDelete(node.path)} className={css['DeleteButton']} title="Delete">
</button>
)}
</div>
{isAddingItem && (
<div className={css['AddItemForm']}>
<input type="text" placeholder="Property key" value={newKey} onChange={(e) => setNewKey(e.target.value)} />
<select value={newItemType} onChange={(e) => setNewItemType(e.target.value as JSONValueType)}>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="null">Null</option>
<option value="array">Array</option>
<option value="object">Object</option>
</select>
<button onClick={handleAddItem} disabled={!newKey.trim()}>
Add
</button>
<button onClick={() => setIsAddingItem(false)}>Cancel</button>
</div>
)}
{hasChildren && (
<div className={css['Children']}>
{node.children!.map((child, idx) => (
<EditableTreeNode
key={child.id || idx}
node={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAdd={onAdd}
disabled={disabled}
/>
))}
</div>
)}
</div>
);
}
// Primitive values
return (
<div className={css['TreeNode']} style={{ marginLeft: `${indent}px` }}>
<div className={css['NodeHeader']}>
{node.key && (
<>
<span className={css['Key']}>{node.key}</span>
<span className={css['Colon']}>:</span>
</>
)}
{node.index !== undefined && <span className={css['Index']}>[{node.index}]</span>}
{typeBadge}
{isEditing ? (
<ValueEditor
value={node.value!}
type={node.type}
onSave={handleSaveEdit}
onCancel={() => setIsEditing(false)}
/>
) : (
<>
<span className={css['Value']} data-type={node.type} onClick={() => canEdit && setIsEditing(true)}>
{formatValue(node.value, node.type)}
</span>
{canEdit && (
<button onClick={() => setIsEditing(true)} className={css['EditButton']} title="Edit">
</button>
)}
</>
)}
{canDelete && (
<button onClick={() => onDelete(node.path)} className={css['DeleteButton']} title="Delete">
</button>
)}
</div>
</div>
);
}
function formatValue(value: unknown, type: string): string {
if (type === 'null') return 'null';
if (type === 'boolean') return value ? 'true' : 'false';
if (type === 'string') return `"${value}"`;
return String(value);
}
/**
* Easy Mode Component
*/
export function EasyMode({ rootNode, onChange, disabled }: EasyModeProps) {
// Handle editing a value at a path
const handleEdit = (path: (string | number)[], value: unknown) => {
if (!onChange) return;
const currentValue = treeNodeToValue(rootNode);
const newValue = setValueAtPath(currentValue, path, value);
const newNode = valueToTreeNode(newValue);
onChange(newNode);
};
// Handle deleting a value at a path
const handleDelete = (path: (string | number)[]) => {
if (!onChange) return;
const currentValue = treeNodeToValue(rootNode);
const newValue = deleteValueAtPath(currentValue, path);
const newNode = valueToTreeNode(newValue);
onChange(newNode);
};
// Handle adding a new item/property
const handleAdd = (path: (string | number)[], type: JSONValueType, key?: string) => {
if (!onChange) return;
const currentValue = treeNodeToValue(rootNode);
// Get the parent value at path
let parent = currentValue;
for (const segment of path) {
parent = (parent as any)[segment];
}
// Create default value for the type
let defaultValue: any;
if (type === 'string') defaultValue = '';
else if (type === 'number') defaultValue = 0;
else if (type === 'boolean') defaultValue = false;
else if (type === 'null') defaultValue = null;
else if (type === 'array') defaultValue = [];
else if (type === 'object') defaultValue = {};
// Add to parent
if (Array.isArray(parent)) {
(parent as any[]).push(defaultValue);
} else if (typeof parent === 'object' && parent !== null && key) {
(parent as any)[key] = defaultValue;
}
const newNode = valueToTreeNode(currentValue);
onChange(newNode);
};
// Empty state
if (!rootNode || (rootNode.type === 'array' && !rootNode.children?.length)) {
return (
<div className={css['Root']}>
<div className={css['EmptyState']}>
<div className={css['EmptyIcon']}>📋</div>
<div className={css['EmptyText']}>Empty array - click "Add Item" to start</div>
</div>
{!disabled && (
<EditableTreeNode
node={rootNode || valueToTreeNode([])}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
disabled={disabled}
/>
)}
</div>
);
}
if (rootNode.type === 'object' && !rootNode.children?.length) {
return (
<div className={css['Root']}>
<div className={css['EmptyState']}>
<div className={css['EmptyIcon']}>📦</div>
<div className={css['EmptyText']}>Empty object - click "Add Property" to start</div>
</div>
{!disabled && (
<EditableTreeNode
node={rootNode || valueToTreeNode({})}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
disabled={disabled}
/>
)}
</div>
);
}
return (
<div className={css['Root']}>
<EditableTreeNode
node={rootNode}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
disabled={disabled}
/>
</div>
);
}

View File

@@ -0,0 +1,121 @@
/**
* ValueEditor Styles
*/
/* Input Editor (String/Number) */
.InputEditor {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.Input {
flex: 1;
padding: 4px 8px;
font-size: 13px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-primary);
border-radius: 3px;
color: var(--theme-color-fg-default);
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
&:focus {
border-color: var(--theme-color-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
&::placeholder {
color: var(--theme-color-fg-muted);
opacity: 0.6;
}
}
.ButtonGroup {
display: flex;
gap: 4px;
}
.SaveButton,
.CancelButton,
.CloseButton {
padding: 4px 8px;
font-size: 14px;
font-weight: bold;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1;
}
.SaveButton {
background-color: var(--theme-color-primary);
color: white;
&:hover {
opacity: 0.9;
}
}
.CancelButton,
.CloseButton {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
&:hover {
background-color: var(--theme-color-bg-2);
}
}
/* Boolean Editor */
.BooleanEditor {
display: flex;
align-items: center;
}
.BooleanToggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 3px;
background-color: var(--theme-color-bg-3);
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-2);
}
input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.ToggleLabel {
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
user-select: none;
}
/* Null Editor */
.NullEditor {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
}
.NullValue {
font-size: 13px;
font-style: italic;
color: #6b7280;
}

View File

@@ -0,0 +1,140 @@
/**
* ValueEditor Component
*
* Type-aware inline editor for primitive JSON values.
* Provides appropriate input controls for each type.
*
* @module json-editor/modes/EasyMode
*/
import React, { useState, useEffect, useRef } from 'react';
import { JSONValueType } from '../../utils/types';
import css from './ValueEditor.module.scss';
export interface ValueEditorProps {
/** Current value */
value: string | number | boolean | null;
/** Value type */
type: JSONValueType;
/** Called when value is saved */
onSave: (value: unknown) => void;
/** Called when editing is cancelled */
onCancel: () => void;
/** Auto-focus on mount */
autoFocus?: boolean;
}
/**
* Inline editor for primitive values
*/
export function ValueEditor({ value, type, onSave, onCancel, autoFocus = true }: ValueEditorProps) {
const [localValue, setLocalValue] = useState<string>(() => {
if (type === 'null') return 'null';
if (type === 'boolean') return value ? 'true' : 'false';
return String(value ?? '');
});
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus on mount
useEffect(() => {
if (autoFocus && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [autoFocus]);
const handleSave = () => {
// Convert string to appropriate type
let parsedValue: unknown;
if (type === 'null') {
parsedValue = null;
} else if (type === 'boolean') {
parsedValue = localValue === 'true';
} else if (type === 'number') {
const num = Number(localValue);
if (isNaN(num)) {
// Invalid number, cancel
onCancel();
return;
}
parsedValue = num;
} else {
// String
parsedValue = localValue;
}
onSave(parsedValue);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
// Boolean toggle
if (type === 'boolean') {
return (
<div className={css['BooleanEditor']}>
<label className={css['BooleanToggle']}>
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="checkbox"
checked={localValue === 'true'}
onChange={(e) => {
const newValue = e.target.checked ? 'true' : 'false';
setLocalValue(newValue);
// Auto-save on toggle
onSave(e.target.checked);
}}
autoFocus={autoFocus}
/>
<span className={css['ToggleLabel']}>{localValue}</span>
</label>
</div>
);
}
// Null (read-only, just show the value)
if (type === 'null') {
return (
<div className={css['NullEditor']}>
<span className={css['NullValue']}>null</span>
<button onClick={onCancel} className={css['CloseButton']}>
</button>
</div>
);
}
// String and Number input
return (
<div className={css['InputEditor']}>
<input
ref={inputRef}
type={type === 'number' ? 'number' : 'text'}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className={css['Input']}
placeholder={type === 'string' ? 'Enter text...' : 'Enter number...'}
/>
<div className={css['ButtonGroup']}>
<button onClick={handleSave} className={css['SaveButton']} title="Save (Enter)">
</button>
<button onClick={onCancel} className={css['CancelButton']} title="Cancel (Esc)">
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,254 @@
/**
* JSON Validator with Helpful Error Messages
*
* Validates JSON and provides detailed error information with
* suggested fixes for common mistakes. Designed to be helpful
* for no-coders who may not understand JSON syntax.
*
* @module json-editor/utils
*/
import { ValidationResult } from './types';
/**
* Validates JSON string and provides detailed error information with fix suggestions.
*
* @param jsonString - The JSON string to validate
* @param expectedType - Optional type constraint ('array' | 'object' | 'any')
* @returns Validation result with error details and suggestions
*
* @example
* ```typescript
* const result = validateJSON('[1, 2, 3]', 'array');
* if (result.valid) {
* console.log('Valid!', result.value);
* } else {
* console.error(result.error, result.suggestion);
* }
* ```
*/
export function validateJSON(jsonString: string, expectedType?: 'array' | 'object' | 'any'): ValidationResult {
// Handle empty input
if (!jsonString || jsonString.trim() === '') {
return {
valid: false,
error: 'Empty input',
suggestion: expectedType === 'array' ? 'Start with [ ] for an array' : 'Start with { } for an object'
};
}
const trimmed = jsonString.trim();
try {
// Attempt to parse
const parsed = JSON.parse(trimmed);
// Check type constraints
if (expectedType === 'array' && !Array.isArray(parsed)) {
return {
valid: false,
error: 'Expected an array, but got an object',
suggestion: 'Wrap your data in [ ] brackets to make it an array. Example: [ {...} ]'
};
}
if (expectedType === 'object' && (typeof parsed !== 'object' || Array.isArray(parsed))) {
return {
valid: false,
error: 'Expected an object, but got ' + (Array.isArray(parsed) ? 'an array' : typeof parsed),
suggestion: 'Wrap your data in { } braces to make it an object. Example: { "key": "value" }'
};
}
// Valid!
return {
valid: true,
value: parsed
};
} catch (error) {
// Parse the error to provide helpful feedback
return parseJSONError(error as SyntaxError, trimmed);
}
}
/**
* Parses a JSON.parse() error and extracts helpful information
*
* @param error - The SyntaxError from JSON.parse()
* @param jsonString - The original JSON string
* @returns Validation result with detailed error and suggestions
*/
function parseJSONError(error: SyntaxError, jsonString: string): ValidationResult {
const message = error.message;
// Extract position from error message
// Error messages typically look like: "Unexpected token } in JSON at position 42"
const positionMatch = message.match(/position (\d+)/);
const position = positionMatch ? parseInt(positionMatch[1], 10) : null;
// Calculate line and column from position
let line = 1;
let column = 1;
if (position !== null) {
const lines = jsonString.substring(0, position).split('\n');
line = lines.length;
column = lines[lines.length - 1].length + 1;
}
// Analyze the error and provide helpful suggestions
const suggestion = getErrorSuggestion(message, jsonString, position);
return {
valid: false,
error: `Line ${line}, Column ${column}: ${extractSimpleError(message)}`,
line,
column,
suggestion
};
}
/**
* Extracts a simple, user-friendly error message from the technical error
*
* @param message - The technical error message from JSON.parse()
* @returns Simplified error message
*/
function extractSimpleError(message: string): string {
// Common patterns
if (message.includes('Unexpected token')) {
const tokenMatch = message.match(/Unexpected token ([^\s]+)/);
const token = tokenMatch ? tokenMatch[1] : 'character';
return `Unexpected ${token}`;
}
if (message.includes('Unexpected end of JSON')) {
return 'Unexpected end of JSON';
}
if (message.includes('Unexpected string')) {
return 'Unexpected string';
}
if (message.includes('Unexpected number')) {
return 'Unexpected number';
}
// Default to original message
return message;
}
/**
* Provides a helpful suggestion based on the error message and context
*
* @param message - The error message from JSON.parse()
* @param jsonString - The original JSON string
* @param position - The error position in the string
* @returns A helpful suggestion for fixing the error
*/
function getErrorSuggestion(message: string, jsonString: string, position: number | null): string {
const lower = message.toLowerCase();
// Missing closing bracket/brace
if (lower.includes('unexpected end of json') || lower.includes('unterminated')) {
const openBraces = (jsonString.match(/{/g) || []).length;
const closeBraces = (jsonString.match(/}/g) || []).length;
const openBrackets = (jsonString.match(/\[/g) || []).length;
const closeBrackets = (jsonString.match(/]/g) || []).length;
if (openBraces > closeBraces) {
return `Missing ${openBraces - closeBraces} closing } brace(s) at the end`;
}
if (openBrackets > closeBrackets) {
return `Missing ${openBrackets - closeBrackets} closing ] bracket(s) at the end`;
}
return 'Check if you have matching { } braces and [ ] brackets';
}
// Missing comma
if (lower.includes('unexpected token') && position !== null) {
const context = jsonString.substring(Math.max(0, position - 20), position);
const nextChar = jsonString[position];
// Check if previous line ended without comma
if (context.includes('"') && (nextChar === '"' || nextChar === '{' || nextChar === '[')) {
return 'Add a comma (,) after the previous property or value';
}
// Unexpected } or ]
if (nextChar === '}' || nextChar === ']') {
return 'Remove the trailing comma before the closing bracket';
}
// Unexpected :
if (nextChar === ':') {
return 'Property keys must be wrapped in "quotes"';
}
}
// Unexpected token } or ]
if (lower.includes('unexpected token }')) {
return 'Either remove this extra } brace, or add a comma before it if there should be more properties';
}
if (lower.includes('unexpected token ]')) {
return 'Either remove this extra ] bracket, or add a comma before it if there should be more items';
}
// Unexpected string
if (lower.includes('unexpected string')) {
return 'Add a comma (,) between values, or check if a property key needs a colon (:)';
}
// Missing quotes around key
if (lower.includes('unexpected token') && position !== null && jsonString[position] !== '"') {
const context = jsonString.substring(Math.max(0, position - 10), position + 10);
if (context.includes(':')) {
return 'Property keys must be wrapped in "double quotes"';
}
}
// Single quotes instead of double quotes
if (jsonString.includes("'")) {
return 'JSON requires "double quotes" for strings, not \'single quotes\'';
}
// Trailing comma
if (lower.includes('unexpected token }') || lower.includes('unexpected token ]')) {
return 'Remove the trailing comma before the closing bracket';
}
// Generic help
return 'Check for common issues: missing commas, unmatched brackets { } [ ], or keys without "quotes"';
}
/**
* Formats JSON string with proper indentation
*
* @param jsonString - The JSON string to format
* @param indent - Number of spaces for indentation (default: 2)
* @returns Formatted JSON string or original if invalid
*/
export function formatJSON(jsonString: string, indent: number = 2): string {
try {
const parsed = JSON.parse(jsonString);
return JSON.stringify(parsed, null, indent);
} catch {
// Return original if invalid
return jsonString;
}
}
/**
* Checks if a string is valid JSON without throwing errors
*
* @param jsonString - The string to check
* @returns True if valid JSON
*/
export function isValidJSON(jsonString: string): boolean {
try {
JSON.parse(jsonString);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,189 @@
/**
* Tree Converter Utility
*
* Converts between JSON values and tree node representations
* for the Easy Mode visual editor.
*
* @module json-editor/utils
*/
import { JSONTreeNode, JSONValueType } from './types';
/**
* Converts a JSON value to a tree node structure for Easy Mode display
*
* @param value - The JSON value to convert
* @param path - Current path in the tree (for editing)
* @param key - Object key (if this is an object property)
* @param index - Array index (if this is an array element)
* @returns Tree node representation
*/
export function valueToTreeNode(
value: unknown,
path: (string | number)[] = [],
key?: string,
index?: number
): JSONTreeNode {
const type = getValueType(value);
const id = path.length === 0 ? 'root' : path.join('.');
const baseNode: JSONTreeNode = {
id,
type,
path,
key,
index,
isExpanded: true // Default to expanded
};
if (type === 'array') {
const arr = value as unknown[];
return {
...baseNode,
children: arr.map((item, idx) => valueToTreeNode(item, [...path, idx], undefined, idx))
};
}
if (type === 'object') {
const obj = value as Record<string, unknown>;
return {
...baseNode,
children: Object.entries(obj).map(([k, v]) => valueToTreeNode(v, [...path, k], k))
};
}
// Primitive values
return {
...baseNode,
value: value as string | number | boolean | null
};
}
/**
* Converts a tree node back to a JSON value
*
* @param node - The tree node to convert
* @returns JSON value
*/
export function treeNodeToValue(node: JSONTreeNode): unknown {
if (node.type === 'array') {
return (node.children || []).map(treeNodeToValue);
}
if (node.type === 'object') {
const result: Record<string, unknown> = {};
(node.children || []).forEach((child) => {
if (child.key) {
result[child.key] = treeNodeToValue(child);
}
});
return result;
}
// Primitive values
return node.value;
}
/**
* Determines the JSON value type
*
* @param value - The value to check
* @returns The JSON value type
*/
export function getValueType(value: unknown): JSONValueType {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
if (typeof value === 'object') return 'object';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
return 'string';
}
/**
* Gets a value from an object/array using a path
*
* @param root - The root object/array
* @param path - Path to the value
* @returns The value at the path, or undefined
*/
export function getValueAtPath(root: unknown, path: (string | number)[]): unknown {
let current = root;
for (const segment of path) {
if (current === null || current === undefined) return undefined;
if (typeof current === 'object') {
current = (current as Record<string | number, unknown>)[segment];
} else {
return undefined;
}
}
return current;
}
/**
* Sets a value in an object/array using a path (immutably)
*
* @param root - The root object/array
* @param path - Path to set the value
* @param value - Value to set
* @returns New root with the value updated
*/
export function setValueAtPath(root: unknown, path: (string | number)[], value: unknown): unknown {
if (path.length === 0) return value;
const [first, ...rest] = path;
if (Array.isArray(root)) {
const newArray = [...root];
if (typeof first === 'number') {
newArray[first] = rest.length === 0 ? value : setValueAtPath(newArray[first], rest, value);
}
return newArray;
}
if (typeof root === 'object' && root !== null) {
return {
...root,
[first]:
rest.length === 0 ? value : setValueAtPath((root as Record<string | number, unknown>)[first], rest, value)
};
}
return root;
}
/**
* Deletes a value in an object/array using a path (immutably)
*
* @param root - The root object/array
* @param path - Path to delete
* @returns New root with the value deleted
*/
export function deleteValueAtPath(root: unknown, path: (string | number)[]): unknown {
if (path.length === 0) return root;
const [first, ...rest] = path;
if (Array.isArray(root)) {
if (rest.length === 0 && typeof first === 'number') {
return root.filter((_, idx) => idx !== first);
}
const newArray = [...root];
if (typeof first === 'number') {
newArray[first] = deleteValueAtPath(newArray[first], rest);
}
return newArray;
}
if (typeof root === 'object' && root !== null) {
if (rest.length === 0) {
const { [first]: _, ...newObj } = root as Record<string | number, unknown>;
return newObj;
}
return {
...root,
[first]: deleteValueAtPath((root as Record<string | number, unknown>)[first], rest)
};
}
return root;
}

View File

@@ -0,0 +1,100 @@
/**
* JSON Editor Component Types
*
* Type definitions for the unified JSON editor component with
* Easy Mode (visual builder) and Advanced Mode (text editor).
*
* @module json-editor
*/
/**
* JSON value types supported by the editor
*/
export type JSONValueType = 'string' | 'number' | 'boolean' | 'null' | 'array' | 'object';
/**
* Validation result with detailed error information and fix suggestions
*/
export interface ValidationResult {
/** Whether the JSON is valid */
valid: boolean;
/** Error message if invalid */
error?: string;
/** Line number where error occurred (1-indexed) */
line?: number;
/** Column number where error occurred (1-indexed) */
column?: number;
/** Suggested fix for the error */
suggestion?: string;
/** Parsed value if valid */
value?: unknown;
}
/**
* Editor mode: Easy (visual) or Advanced (text)
*/
export type EditorMode = 'easy' | 'advanced';
/**
* Props for the main JSONEditor component
*/
export interface JSONEditorProps {
/** Initial value (JSON string or parsed object/array) */
value: string | object | unknown[];
/** Called when value changes (debounced) */
onChange: (value: string) => void;
/** Called on explicit save (Cmd+S or button) */
onSave?: (value: string) => void;
/** Initial mode - defaults to 'easy' */
defaultMode?: EditorMode;
/** Force a specific mode (no toggle shown) */
mode?: EditorMode;
/** Type constraint for validation */
expectedType?: 'array' | 'object' | 'any';
/** Readonly mode */
disabled?: boolean;
/** Height constraint */
height?: number | string;
/** Custom placeholder for empty state */
placeholder?: string;
}
/**
* Internal tree node representation for Easy Mode
*/
export interface JSONTreeNode {
/** Unique identifier for this node */
id: string;
/** Node type */
type: JSONValueType;
/** For object keys */
key?: string;
/** For array indices */
index?: number;
/** The actual value (primitives) or undefined (arrays/objects) */
value?: string | number | boolean | null;
/** Child nodes for arrays/objects */
children?: JSONTreeNode[];
/** Whether this node is expanded */
isExpanded?: boolean;
/** Path to this node (for editing) */
path: (string | number)[];
}
/**
* Action types for tree node operations
*/
export type TreeAction =
| { type: 'add'; path: (string | number)[]; valueType: JSONValueType; key?: string }
| { type: 'edit'; path: (string | number)[]; value: unknown }
| { type: 'delete'; path: (string | number)[] }
| { type: 'toggle'; path: (string | number)[] }
| { type: 'reorder'; path: (string | number)[]; fromIndex: number; toIndex: number };