mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Added custom json edit to config tab
This commit is contained in:
@@ -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);
|
||||
}
|
||||
158
packages/noodl-core-ui/src/components/json-editor/JSONEditor.tsx
Normal file
158
packages/noodl-core-ui/src/components/json-editor/JSONEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
packages/noodl-core-ui/src/components/json-editor/index.ts
Normal file
11
packages/noodl-core-ui/src/components/json-editor/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
100
packages/noodl-core-ui/src/components/json-editor/utils/types.ts
Normal file
100
packages/noodl-core-ui/src/components/json-editor/utils/types.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user