mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Merge remote-tracking branch 'origin/cline-dev' into cline-dev-tara
:wq Merge remote-tracking branch 'origin/cline-dev' into cline-dev-tara
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 };
|
||||
@@ -21,7 +21,7 @@ import '../editor/src/styles/custom-properties/spacing.css';
|
||||
import Router from './src/router';
|
||||
|
||||
// Build canary: Verify fresh code is loading
|
||||
console.log('🔥 BUILD TIMESTAMP:', new Date().toISOString());
|
||||
console.log('🔥🔥 BUILD TIMESTAMP:', new Date().toISOString());
|
||||
|
||||
ipcRenderer.on('open-noodl-uri', async (event, uri) => {
|
||||
if (uri.startsWith('noodl:import/http')) {
|
||||
|
||||
@@ -881,6 +881,82 @@ export class ProjectModel extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
// App Configuration Methods
|
||||
/**
|
||||
* Gets the app configuration from project metadata.
|
||||
* @returns The app config object
|
||||
*/
|
||||
getAppConfig() {
|
||||
// Import types dynamically to avoid circular dependencies
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { DEFAULT_APP_CONFIG } = require('@noodl/runtime/src/config/types');
|
||||
return this.getMetaData('appConfig') || DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the app configuration in project metadata.
|
||||
* @param config - The app config to save
|
||||
*/
|
||||
setAppConfig(config) {
|
||||
this.setMetaData('appConfig', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the app configuration with partial values.
|
||||
* @param updates - Partial app config updates
|
||||
*/
|
||||
updateAppConfig(updates) {
|
||||
const current = this.getAppConfig();
|
||||
this.setAppConfig({
|
||||
...current,
|
||||
...updates,
|
||||
identity: {
|
||||
...current.identity,
|
||||
...(updates.identity || {})
|
||||
},
|
||||
seo: {
|
||||
...current.seo,
|
||||
...(updates.seo || {})
|
||||
},
|
||||
pwa: updates.pwa ? { ...current.pwa, ...updates.pwa } : current.pwa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all config variables.
|
||||
* @returns Array of config variables
|
||||
*/
|
||||
getConfigVariables() {
|
||||
return this.getAppConfig().variables || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or updates a config variable.
|
||||
* @param variable - The config variable to set
|
||||
*/
|
||||
setConfigVariable(variable) {
|
||||
const config = this.getAppConfig();
|
||||
const index = config.variables.findIndex((v) => v.key === variable.key);
|
||||
|
||||
if (index >= 0) {
|
||||
config.variables[index] = variable;
|
||||
} else {
|
||||
config.variables.push(variable);
|
||||
}
|
||||
|
||||
this.setAppConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a config variable by key.
|
||||
* @param key - The variable key to remove
|
||||
*/
|
||||
removeConfigVariable(key: string) {
|
||||
const config = this.getAppConfig();
|
||||
config.variables = config.variables.filter((v) => v.key !== key);
|
||||
this.setAppConfig(config);
|
||||
}
|
||||
|
||||
createNewVariant(name, node) {
|
||||
let variant = this.variants.find((v) => v.name === name && v.typename === node.type.localName);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import config from '../../shared/config/config';
|
||||
import { ComponentDiffDocumentProvider } from './views/documents/ComponentDiffDocument';
|
||||
import { EditorDocumentProvider } from './views/documents/EditorDocument';
|
||||
import { AppSetupPanel } from './views/panels/AppSetupPanel/AppSetupPanel';
|
||||
import { BackendServicesPanel } from './views/panels/BackendServicesPanel/BackendServicesPanel';
|
||||
import { CloudFunctionsPanel } from './views/panels/CloudFunctionsPanel/CloudFunctionsPanel';
|
||||
import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudServicePanel';
|
||||
@@ -148,6 +149,15 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
|
||||
panel: BackendServicesPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: 'app-setup',
|
||||
name: 'App Setup',
|
||||
isDisabled: isLesson === true,
|
||||
order: 8.5,
|
||||
icon: IconName.Sliders,
|
||||
panel: AppSetupPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: 'settings',
|
||||
name: 'Project settings',
|
||||
|
||||
@@ -26,6 +26,13 @@ export interface createModelOptions {
|
||||
* Create the Monaco Model, with better typings etc
|
||||
*/
|
||||
export function createModel(options: createModelOptions, node: NodeGraphNode): EditorModel {
|
||||
// Simple JSON editing - use plaintext (no workers needed)
|
||||
// Monaco workers require initialization from node context; plaintext avoids this.
|
||||
// JSON validation happens on save via JSON.parse()
|
||||
if (options.codeeditor === 'json') {
|
||||
return new EditorModel(monaco.editor.createModel(options.value, 'plaintext'));
|
||||
}
|
||||
|
||||
// arrays are edited as javascript (and eval:ed during runtime)
|
||||
// we are not going to add any extra typings here.
|
||||
if (options.type === 'array') {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
|
||||
|
||||
import { IdentitySection } from './sections/IdentitySection';
|
||||
import { PWASection } from './sections/PWASection';
|
||||
import { SEOSection } from './sections/SEOSection';
|
||||
import { VariablesSection } from './sections/VariablesSection';
|
||||
|
||||
export function AppSetupPanel() {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Listen for metadata changes to refresh the panel
|
||||
useEventListener(
|
||||
ProjectModel.instance,
|
||||
'ProjectModel.metadataChanged',
|
||||
useCallback((data: { key: string }) => {
|
||||
if (data.key === 'appConfig') {
|
||||
forceUpdate((prev) => prev + 1);
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
const config = ProjectModel.instance.getAppConfig();
|
||||
|
||||
const updateIdentity = useCallback((updates: Partial<typeof config.identity>) => {
|
||||
const currentConfig = ProjectModel.instance.getAppConfig();
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
identity: { ...currentConfig.identity, ...updates }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSEO = useCallback((updates: Partial<typeof config.seo>) => {
|
||||
const currentConfig = ProjectModel.instance.getAppConfig();
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
seo: { ...currentConfig.seo, ...updates }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updatePWA = useCallback((updates: Partial<NonNullable<typeof config.pwa>>) => {
|
||||
const currentConfig = ProjectModel.instance.getAppConfig();
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
pwa: { ...(currentConfig.pwa || {}), ...updates } as NonNullable<typeof config.pwa>
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateVariables = useCallback((variables: typeof config.variables) => {
|
||||
// Defer the update to avoid re-render race condition
|
||||
setTimeout(() => {
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
variables
|
||||
});
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BasePanel title="App Setup" hasContentScroll>
|
||||
<IdentitySection identity={config.identity} onChange={updateIdentity} />
|
||||
|
||||
<SEOSection seo={config.seo} identity={config.identity} onChange={updateSEO} />
|
||||
|
||||
<PWASection pwa={config.pwa} onChange={updatePWA} />
|
||||
|
||||
<VariablesSection variables={config.variables} onChange={updateVariables} />
|
||||
</BasePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppIdentity } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
interface IdentitySectionProps {
|
||||
identity: AppIdentity;
|
||||
onChange: (updates: Partial<AppIdentity>) => void;
|
||||
}
|
||||
|
||||
export function IdentitySection({ identity, onChange }: IdentitySectionProps) {
|
||||
// Local state for immediate textarea updates
|
||||
const [localDescription, setLocalDescription] = useState(identity.description || '');
|
||||
|
||||
// Sync local state when external identity changes
|
||||
useEffect(() => {
|
||||
setLocalDescription(identity.description || '');
|
||||
}, [identity.description]);
|
||||
|
||||
return (
|
||||
<CollapsableSection title="App Identity" hasGutter hasVisibleOverflow>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
App Name
|
||||
</label>
|
||||
<PropertyPanelTextInput value={identity.appName || ''} onChange={(value) => onChange({ appName: value })} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={localDescription}
|
||||
onChange={(e) => {
|
||||
setLocalDescription(e.target.value);
|
||||
onChange({ description: e.target.value });
|
||||
}}
|
||||
placeholder="Describe your app..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Cover Image
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={identity.coverImage || ''}
|
||||
onChange={(value) => onChange({ coverImage: value })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Path to cover image in project
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppPWA } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
interface PWASectionProps {
|
||||
pwa: AppPWA | undefined;
|
||||
onChange: (updates: Partial<AppPWA>) => void;
|
||||
}
|
||||
|
||||
const DISPLAY_MODES = [
|
||||
{ value: 'standalone', label: 'Standalone (Recommended)' },
|
||||
{ value: 'fullscreen', label: 'Fullscreen' },
|
||||
{ value: 'minimal-ui', label: 'Minimal UI' },
|
||||
{ value: 'browser', label: 'Browser' }
|
||||
] as const;
|
||||
|
||||
export function PWASection({ pwa, onChange }: PWASectionProps) {
|
||||
// Local state for immediate UI updates
|
||||
const [localEnabled, setLocalEnabled] = useState(pwa?.enabled || false);
|
||||
const [localDisplay, setLocalDisplay] = useState<AppPWA['display']>(pwa?.display || 'standalone');
|
||||
const [localBgColor, setLocalBgColor] = useState(pwa?.backgroundColor || '#ffffff');
|
||||
|
||||
// Sync local state when external pwa changes
|
||||
useEffect(() => {
|
||||
setLocalEnabled(pwa?.enabled || false);
|
||||
}, [pwa?.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDisplay(pwa?.display || 'standalone');
|
||||
}, [pwa?.display]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalBgColor(pwa?.backgroundColor || '#ffffff');
|
||||
}, [pwa?.backgroundColor]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!localEnabled) {
|
||||
// Enable with defaults
|
||||
setLocalEnabled(true); // Immediate UI update
|
||||
onChange({
|
||||
enabled: true,
|
||||
startUrl: '/',
|
||||
display: 'standalone'
|
||||
});
|
||||
} else {
|
||||
// Disable
|
||||
setLocalEnabled(false); // Immediate UI update
|
||||
onChange({ enabled: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsableSection title="Progressive Web App" hasGutter hasVisibleOverflow hasTopDivider>
|
||||
{/* Enable PWA Toggle */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localEnabled}
|
||||
onChange={handleToggle}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
Enable Progressive Web App
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px',
|
||||
marginLeft: '24px'
|
||||
}}
|
||||
>
|
||||
Allow users to install your app on their device
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PWA Configuration Fields - Only shown when enabled */}
|
||||
{localEnabled && (
|
||||
<>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Short Name
|
||||
</label>
|
||||
<PropertyPanelTextInput value={pwa?.shortName || ''} onChange={(value) => onChange({ shortName: value })} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Name shown on home screen (12 chars max recommended)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Start URL
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={pwa?.startUrl || '/'}
|
||||
onChange={(value) => onChange({ startUrl: value || '/' })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
URL the app opens to (default: /)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Display Mode
|
||||
</label>
|
||||
<select
|
||||
value={localDisplay}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value as AppPWA['display'];
|
||||
setLocalDisplay(newValue);
|
||||
onChange({ display: newValue });
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{DISPLAY_MODES.map((mode) => (
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
How the app appears when launched
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Background Color
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={localBgColor}
|
||||
onChange={(e) => {
|
||||
setLocalBgColor(e.target.value);
|
||||
onChange({ backgroundColor: e.target.value });
|
||||
}}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '32px',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PropertyPanelTextInput
|
||||
value={localBgColor}
|
||||
onChange={(value) => {
|
||||
setLocalBgColor(value);
|
||||
onChange({ backgroundColor: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Splash screen background color
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Source Icon
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={pwa?.sourceIcon || ''}
|
||||
onChange={(value) => onChange({ sourceIcon: value })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Path to 512x512 icon (generates all sizes automatically)
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppSEO, AppIdentity } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
interface SEOSectionProps {
|
||||
seo: AppSEO;
|
||||
identity: AppIdentity;
|
||||
onChange: (updates: Partial<AppSEO>) => void;
|
||||
}
|
||||
|
||||
export function SEOSection({ seo, identity, onChange }: SEOSectionProps) {
|
||||
// Local state for immediate UI updates
|
||||
const [localOgDescription, setLocalOgDescription] = useState(seo.ogDescription || '');
|
||||
const [localThemeColor, setLocalThemeColor] = useState(seo.themeColor || '#000000');
|
||||
|
||||
// Sync local state when external seo changes
|
||||
useEffect(() => {
|
||||
setLocalOgDescription(seo.ogDescription || '');
|
||||
}, [seo.ogDescription]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalThemeColor(seo.themeColor || '#000000');
|
||||
}, [seo.themeColor]);
|
||||
|
||||
// Smart defaults from identity
|
||||
const defaultOgTitle = identity.appName || '';
|
||||
const defaultOgDescription = identity.description || '';
|
||||
const defaultOgImage = identity.coverImage || '';
|
||||
|
||||
return (
|
||||
<CollapsableSection title="SEO & Metadata" hasGutter hasVisibleOverflow hasTopDivider>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Open Graph Title
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={seo.ogTitle || ''}
|
||||
onChange={(value) => onChange({ ogTitle: value || undefined })}
|
||||
/>
|
||||
{!seo.ogTitle && defaultOgTitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Defaults to: {defaultOgTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Open Graph Description
|
||||
</label>
|
||||
<textarea
|
||||
value={localOgDescription}
|
||||
onChange={(e) => {
|
||||
setLocalOgDescription(e.target.value);
|
||||
onChange({ ogDescription: e.target.value || undefined });
|
||||
}}
|
||||
placeholder="Enter description..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
{!seo.ogDescription && defaultOgDescription && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Defaults to: {defaultOgDescription.substring(0, 50)}
|
||||
{defaultOgDescription.length > 50 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Open Graph Image
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={seo.ogImage || ''}
|
||||
onChange={(value) => onChange({ ogImage: value || undefined })}
|
||||
/>
|
||||
{!seo.ogImage && defaultOgImage && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Defaults to: {defaultOgImage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Favicon
|
||||
</label>
|
||||
<PropertyPanelTextInput value={seo.favicon || ''} onChange={(value) => onChange({ favicon: value })} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Path to favicon (.ico, .png, .svg)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Theme Color
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={localThemeColor}
|
||||
onChange={(e) => {
|
||||
setLocalThemeColor(e.target.value);
|
||||
onChange({ themeColor: e.target.value });
|
||||
}}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '32px',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PropertyPanelTextInput
|
||||
value={localThemeColor}
|
||||
onChange={(value) => {
|
||||
setLocalThemeColor(value);
|
||||
onChange({ themeColor: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Browser theme color (hex format)
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ConfigVariable, ConfigType, RESERVED_CONFIG_KEYS } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { JSONEditor } from '@noodl-core-ui/components/json-editor';
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
import PopupLayer from '../../../popuplayer';
|
||||
|
||||
interface VariablesSectionProps {
|
||||
variables: ConfigVariable[];
|
||||
onChange: (variables: ConfigVariable[]) => void;
|
||||
}
|
||||
|
||||
// Separate component for JSON editor button that manages its own ref
|
||||
interface JSONEditorButtonProps {
|
||||
value: string;
|
||||
varType: 'array' | 'object';
|
||||
onSave: (value: string) => void;
|
||||
}
|
||||
|
||||
function JSONEditorButton({ value, varType, onSave }: JSONEditorButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoutRef = useRef<any>(null);
|
||||
const rootRef = useRef<any>(null);
|
||||
|
||||
// Cleanup editor on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rootRef.current) {
|
||||
rootRef.current.unmount();
|
||||
rootRef.current = null;
|
||||
}
|
||||
if (popoutRef.current) {
|
||||
popoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openEditor = () => {
|
||||
if (!buttonRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing editor
|
||||
if (rootRef.current) {
|
||||
rootRef.current.unmount();
|
||||
rootRef.current = null;
|
||||
}
|
||||
|
||||
// Create popup container
|
||||
const popupDiv = document.createElement('div');
|
||||
const root = createRoot(popupDiv);
|
||||
rootRef.current = root;
|
||||
|
||||
// Track current value for save
|
||||
let currentValue = value;
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
currentValue = newValue;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
try {
|
||||
// Parse and validate before saving
|
||||
const parsed = JSON.parse(currentValue);
|
||||
if (varType === 'array' && !Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
if (varType === 'object' && (typeof parsed !== 'object' || Array.isArray(parsed))) {
|
||||
return;
|
||||
}
|
||||
onSave(currentValue);
|
||||
} catch {
|
||||
// Invalid JSON - don't save
|
||||
}
|
||||
|
||||
root.unmount();
|
||||
rootRef.current = null;
|
||||
};
|
||||
|
||||
root.render(
|
||||
<div style={{ padding: '16px', width: '600px', height: '500px', display: 'flex', flexDirection: 'column' }}>
|
||||
<JSONEditor value={value} onChange={handleChange} expectedType={varType} height="100%" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const popout = PopupLayer.instance.showPopout({
|
||||
content: { el: [popupDiv] },
|
||||
attachTo: $(buttonRef.current),
|
||||
position: 'right',
|
||||
onClose: handleClose
|
||||
});
|
||||
|
||||
popoutRef.current = popout;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditor();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
color: 'var(--theme-color-fg-muted)'
|
||||
}}
|
||||
>
|
||||
{value || (varType === 'array' ? '[]' : '{}')}
|
||||
</span>
|
||||
<span style={{ marginLeft: '8px', color: 'var(--theme-color-primary)' }}>Edit JSON ➜</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const ALL_TYPES = [
|
||||
{ value: 'string' as const, label: 'String' },
|
||||
{ value: 'number' as const, label: 'Number' },
|
||||
{ value: 'boolean' as const, label: 'Boolean' },
|
||||
{ value: 'color' as const, label: 'Color' },
|
||||
{ value: 'array' as const, label: 'Array' },
|
||||
{ value: 'object' as const, label: 'Object' }
|
||||
];
|
||||
|
||||
export function VariablesSection({ variables, onChange }: VariablesSectionProps) {
|
||||
// Local state for optimistic updates
|
||||
const [localVariables, setLocalVariables] = useState<ConfigVariable[]>(variables);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// Sync with props when they change externally
|
||||
useEffect(() => {
|
||||
setLocalVariables(variables);
|
||||
}, [variables]);
|
||||
|
||||
// New variable form state
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newType, setNewType] = useState<ConfigType>('string');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [newCategory, setNewCategory] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
const validateKey = (key: string, excludeIndex?: number): string | null => {
|
||||
if (!key.trim()) {
|
||||
return 'Key is required';
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
return 'Key must start with letter or underscore, and contain only letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
if (RESERVED_CONFIG_KEYS.includes(key as (typeof RESERVED_CONFIG_KEYS)[number])) {
|
||||
return `"${key}" is a reserved key`;
|
||||
}
|
||||
|
||||
const isDuplicate = localVariables.some((v, index) => v.key === key && index !== excludeIndex);
|
||||
if (isDuplicate) {
|
||||
return `Key "${key}" already exists`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseValue = (type: ConfigType, valueStr: string): unknown => {
|
||||
if (type === 'string') return valueStr;
|
||||
if (type === 'number') {
|
||||
const num = Number(valueStr);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
if (type === 'boolean') return valueStr === 'true';
|
||||
if (type === 'color') return valueStr || '#000000';
|
||||
if (type === 'array') {
|
||||
try {
|
||||
const parsed = JSON.parse(valueStr || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (type === 'object') {
|
||||
try {
|
||||
const parsed = JSON.parse(valueStr || '{}');
|
||||
return typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return valueStr;
|
||||
};
|
||||
|
||||
const serializeValue = (variable: ConfigVariable): string => {
|
||||
if (variable.type === 'string') return String(variable.value || '');
|
||||
if (variable.type === 'number') return String(variable.value || 0);
|
||||
if (variable.type === 'boolean') return String(variable.value);
|
||||
if (variable.type === 'color') return String(variable.value || '#000000');
|
||||
if (variable.type === 'array' || variable.type === 'object') {
|
||||
try {
|
||||
return JSON.stringify(variable.value, null, 2);
|
||||
} catch {
|
||||
return variable.type === 'array' ? '[]' : '{}';
|
||||
}
|
||||
}
|
||||
return String(variable.value || '');
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const keyError = validateKey(newKey);
|
||||
if (keyError) {
|
||||
setError(keyError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON for array/object types
|
||||
if ((newType === 'array' || newType === 'object') && newValue) {
|
||||
try {
|
||||
JSON.parse(newValue);
|
||||
setJsonError('');
|
||||
} catch {
|
||||
setJsonError('Invalid JSON');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newVariable: ConfigVariable = {
|
||||
key: newKey.trim(),
|
||||
type: newType,
|
||||
value: parseValue(newType, newValue),
|
||||
description: newDescription.trim() || undefined,
|
||||
category: newCategory.trim() || undefined
|
||||
};
|
||||
|
||||
// Update local state immediately (optimistic update)
|
||||
const updated = [...localVariables, newVariable];
|
||||
setLocalVariables(updated);
|
||||
|
||||
// Persist in background
|
||||
onChange(updated);
|
||||
|
||||
// Reset form
|
||||
setNewKey('');
|
||||
setNewType('string');
|
||||
setNewValue('');
|
||||
setNewDescription('');
|
||||
setNewCategory('');
|
||||
setError('');
|
||||
setJsonError('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, updates: Partial<ConfigVariable>) => {
|
||||
// Update local state immediately (optimistic update)
|
||||
const updated = [...localVariables];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
setLocalVariables(updated);
|
||||
|
||||
// Persist in background
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
// Update local state immediately (optimistic update)
|
||||
const updated = localVariables.filter((_, i) => i !== index);
|
||||
setLocalVariables(updated);
|
||||
|
||||
// Persist in background
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsAdding(false);
|
||||
setNewKey('');
|
||||
setNewType('string');
|
||||
setNewValue('');
|
||||
setNewDescription('');
|
||||
setNewCategory('');
|
||||
setError('');
|
||||
setJsonError('');
|
||||
};
|
||||
|
||||
const renderValueInput = (type: ConfigType, value: string, onValueChange: (v: string) => void): React.ReactNode => {
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value === 'true'}
|
||||
onChange={(e) => onValueChange(e.target.checked ? 'true' : 'false')}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value || ''}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder="0"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'color') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value || '#000000'}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '32px',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PropertyPanelTextInput value={value || '#000000'} onChange={onValueChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'array' || type === 'object') {
|
||||
return (
|
||||
<JSONEditorButton
|
||||
value={value || (type === 'array' ? '[]' : '{}')}
|
||||
varType={type}
|
||||
onSave={(newValue) => {
|
||||
onValueChange(newValue);
|
||||
setJsonError('');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <PropertyPanelTextInput value={value} onChange={onValueChange} />;
|
||||
};
|
||||
|
||||
// Group variables by category
|
||||
const groupedVariables: { [category: string]: ConfigVariable[] } = {};
|
||||
localVariables.forEach((variable) => {
|
||||
const cat = variable.category || 'Uncategorized';
|
||||
if (!groupedVariables[cat]) {
|
||||
groupedVariables[cat] = [];
|
||||
}
|
||||
groupedVariables[cat].push(variable);
|
||||
});
|
||||
|
||||
const categories = Object.keys(groupedVariables).sort((a, b) => {
|
||||
// Uncategorized always first
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<CollapsableSection title="Custom Variables" hasGutter hasVisibleOverflow hasTopDivider>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginBottom: '12px'
|
||||
}}
|
||||
>
|
||||
Define custom config variables accessible via Noodl.Config.get('key')
|
||||
</div>
|
||||
|
||||
{/* Add Variable Button + Clear All */}
|
||||
{!isAdding && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--theme-color-primary)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
+ Add Variable
|
||||
</button>
|
||||
{localVariables.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete all ${localVariables.length} variables?`)) {
|
||||
setLocalVariables([]);
|
||||
onChange([]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Variable Form */}
|
||||
{isAdding && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
marginBottom: '12px',
|
||||
backgroundColor: 'var(--theme-color-bg-2)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Key *
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={newKey}
|
||||
onChange={(value) => {
|
||||
setNewKey(value);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => {
|
||||
const newT = e.target.value as ConfigType;
|
||||
setNewType(newT);
|
||||
// Set appropriate default values
|
||||
if (newT === 'boolean') setNewValue('false');
|
||||
else if (newT === 'number') setNewValue('0');
|
||||
else if (newT === 'color') setNewValue('#000000');
|
||||
else if (newT === 'array') setNewValue('[]');
|
||||
else if (newT === 'object') setNewValue('{}');
|
||||
else setNewValue('');
|
||||
setJsonError('');
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{ALL_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput(newType, newValue, setNewValue)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Category (optional)
|
||||
</label>
|
||||
<PropertyPanelTextInput value={newCategory} onChange={setNewCategory} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<PropertyPanelTextInput value={newDescription} onChange={setNewDescription} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--theme-color-error)',
|
||||
backgroundColor: 'var(--theme-color-error-bg)',
|
||||
border: '1px solid var(--theme-color-error)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jsonError && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--theme-color-error)',
|
||||
backgroundColor: 'var(--theme-color-error-bg)',
|
||||
border: '1px solid var(--theme-color-error)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{jsonError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!!error || !!jsonError}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: error || jsonError ? 'var(--theme-color-bg-3)' : 'var(--theme-color-primary)',
|
||||
color: error || jsonError ? 'var(--theme-color-fg-muted)' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: error || jsonError ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variables List - Grouped by Category */}
|
||||
{localVariables.length === 0 && !isAdding && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
backgroundColor: 'var(--theme-color-bg-2)',
|
||||
border: '1px dashed var(--theme-color-border-default)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
No variables defined yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.map((category) => (
|
||||
<div key={category} style={{ marginBottom: '16px' }}>
|
||||
{categories.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginBottom: '8px',
|
||||
letterSpacing: '0.5px'
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedVariables[category].map((variable) => {
|
||||
const index = localVariables.indexOf(variable);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: 'var(--theme-color-bg-2)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
{variable.key}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '3px',
|
||||
color: 'var(--theme-color-fg-muted)'
|
||||
}}
|
||||
>
|
||||
{variable.type}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(index)}
|
||||
title="Delete variable"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
minWidth: '28px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-muted)'
|
||||
}}
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput(variable.type, serializeValue(variable), (value) =>
|
||||
handleUpdate(index, { value: parseValue(variable.type, value) })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variable.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
fontStyle: 'italic',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
{variable.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ module.exports = merge(
|
||||
merge(shared, {
|
||||
entry: {
|
||||
'./src/editor/index': './src/editor/index.ts',
|
||||
'./src/frames/viewer-frame/index': './src/frames/viewer-frame/index.js',
|
||||
'./src/frames/viewer-frame/index': './src/frames/viewer-frame/index.js'
|
||||
},
|
||||
output: {
|
||||
filename: '[name].bundle.js',
|
||||
@@ -17,7 +17,9 @@ module.exports = merge(
|
||||
},
|
||||
plugins: [
|
||||
new MonacoWebpackPlugin({
|
||||
languages: ['typescript', 'javascript', 'css']
|
||||
// JSON language worker is broken in Electron/CommonJS - use TypeScript for JSON editing
|
||||
languages: ['typescript', 'javascript', 'css'],
|
||||
globalAPI: true
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
226
packages/noodl-runtime/src/config/config-manager.test.ts
Normal file
226
packages/noodl-runtime/src/config/config-manager.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Tests for ConfigManager
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
|
||||
import { ConfigManager } from './config-manager';
|
||||
import { DEFAULT_APP_CONFIG } from './types';
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let manager: ConfigManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = ConfigManager.getInstance();
|
||||
manager.reset(); // Reset state between tests
|
||||
});
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
it('should return the same instance', () => {
|
||||
const instance1 = ConfigManager.getInstance();
|
||||
const instance2 = ConfigManager.getInstance();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should initialize with default config', () => {
|
||||
manager.initialize({});
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.identity.appName).toBe(DEFAULT_APP_CONFIG.identity.appName);
|
||||
});
|
||||
|
||||
it('should merge partial config with defaults', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'My App',
|
||||
description: 'Test'
|
||||
}
|
||||
});
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.identity.appName).toBe('My App');
|
||||
expect(config.variables).toEqual([]);
|
||||
});
|
||||
|
||||
it('should store custom variables', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: [{ key: 'apiKey', type: 'string', value: 'secret123' }]
|
||||
});
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.variables.length).toBe(1);
|
||||
expect(config.variables[0].key).toBe('apiKey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('should return flattened config object', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description'
|
||||
},
|
||||
seo: {},
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string', value: 'key123' },
|
||||
{ key: 'maxRetries', type: 'number', value: 3 }
|
||||
]
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.appName).toBe('Test App');
|
||||
expect(config.description).toBe('Test Description');
|
||||
expect(config.apiKey).toBe('key123');
|
||||
expect(config.maxRetries).toBe(3);
|
||||
});
|
||||
|
||||
it('should apply smart defaults for SEO', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description',
|
||||
coverImage: 'cover.jpg'
|
||||
},
|
||||
seo: {}, // Empty SEO
|
||||
variables: []
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
// Should default to identity values
|
||||
expect(config.ogTitle).toBe('Test App');
|
||||
expect(config.ogDescription).toBe('Test Description');
|
||||
expect(config.ogImage).toBe('cover.jpg');
|
||||
});
|
||||
|
||||
it('should use explicit SEO values when provided', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description'
|
||||
},
|
||||
seo: {
|
||||
ogTitle: 'Custom OG Title',
|
||||
ogDescription: 'Custom OG Description'
|
||||
},
|
||||
variables: []
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.ogTitle).toBe('Custom OG Title');
|
||||
expect(config.ogDescription).toBe('Custom OG Description');
|
||||
});
|
||||
|
||||
it('should return frozen object', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: []
|
||||
});
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(Object.isFrozen(config)).toBe(true);
|
||||
|
||||
// Attempt to modify should fail silently (or throw in strict mode)
|
||||
expect(() => {
|
||||
(config as any).appName = 'Modified';
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariable', () => {
|
||||
beforeEach(() => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string', value: 'key123', description: 'API Key' },
|
||||
{ key: 'maxRetries', type: 'number', value: 3 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should return variable by key', () => {
|
||||
const variable = manager.getVariable('apiKey');
|
||||
expect(variable).toBeDefined();
|
||||
expect(variable?.value).toBe('key123');
|
||||
expect(variable?.description).toBe('API Key');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent key', () => {
|
||||
const variable = manager.getVariable('nonExistent');
|
||||
expect(variable).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariableKeys', () => {
|
||||
it('should return all variable keys', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string', value: 'key123' },
|
||||
{ key: 'maxRetries', type: 'number', value: 3 },
|
||||
{ key: 'enabled', type: 'boolean', value: true }
|
||||
]
|
||||
});
|
||||
|
||||
const keys = manager.getVariableKeys();
|
||||
expect(keys).toEqual(['apiKey', 'maxRetries', 'enabled']);
|
||||
});
|
||||
|
||||
it('should return empty array when no variables', () => {
|
||||
manager.initialize({
|
||||
identity: DEFAULT_APP_CONFIG.identity,
|
||||
seo: {},
|
||||
variables: []
|
||||
});
|
||||
|
||||
const keys = manager.getVariableKeys();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('should not allow mutation of returned config', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Original',
|
||||
description: ''
|
||||
},
|
||||
seo: {},
|
||||
variables: [{ key: 'apiKey', type: 'string', value: 'original' }]
|
||||
});
|
||||
|
||||
const config1 = manager.getConfig();
|
||||
|
||||
// Attempt mutations should fail
|
||||
expect(() => {
|
||||
(config1 as any).apiKey = 'modified';
|
||||
}).toThrow();
|
||||
|
||||
// Getting config again should still have original value
|
||||
const config2 = manager.getConfig();
|
||||
expect(config2.apiKey).toBe('original');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset to default state', () => {
|
||||
manager.initialize({
|
||||
identity: {
|
||||
appName: 'Custom App',
|
||||
description: 'Custom'
|
||||
},
|
||||
seo: {},
|
||||
variables: [{ key: 'test', type: 'string', value: 'value' }]
|
||||
});
|
||||
|
||||
manager.reset();
|
||||
|
||||
const config = manager.getRawConfig();
|
||||
expect(config.identity.appName).toBe(DEFAULT_APP_CONFIG.identity.appName);
|
||||
expect(config.variables).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
packages/noodl-runtime/src/config/config-manager.ts
Normal file
167
packages/noodl-runtime/src/config/config-manager.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Configuration Manager
|
||||
*
|
||||
* Central manager for app configuration. Initializes config at app startup
|
||||
* and provides immutable access to configuration values via Noodl.Config.
|
||||
*
|
||||
* @module config/config-manager
|
||||
*/
|
||||
|
||||
import { AppConfig, ConfigVariable, DEFAULT_APP_CONFIG } from './types';
|
||||
|
||||
/**
|
||||
* Deep freezes an object recursively to prevent any mutation.
|
||||
*/
|
||||
function deepFreeze<T extends object>(obj: T): Readonly<T> {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const value = (obj as Record<string, unknown>)[key];
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
deepFreeze(value as object);
|
||||
}
|
||||
});
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigManager is a singleton that manages the app configuration.
|
||||
* It is initialized once at app startup with config from project metadata.
|
||||
*/
|
||||
class ConfigManager {
|
||||
private static instance: ConfigManager;
|
||||
private config: AppConfig = DEFAULT_APP_CONFIG;
|
||||
private frozenConfig: Readonly<Record<string, unknown>> | null = null;
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance.
|
||||
*/
|
||||
static getInstance(): ConfigManager {
|
||||
if (!ConfigManager.instance) {
|
||||
ConfigManager.instance = new ConfigManager();
|
||||
}
|
||||
return ConfigManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the configuration from project metadata.
|
||||
* This should be called once at app startup.
|
||||
*
|
||||
* @param config - Partial app config from project.json metadata
|
||||
*/
|
||||
initialize(config: Partial<AppConfig>): void {
|
||||
// Merge with defaults
|
||||
this.config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...config,
|
||||
identity: {
|
||||
...DEFAULT_APP_CONFIG.identity,
|
||||
...config.identity
|
||||
},
|
||||
seo: {
|
||||
...DEFAULT_APP_CONFIG.seo,
|
||||
...config.seo
|
||||
},
|
||||
variables: config.variables || []
|
||||
};
|
||||
|
||||
// Build and freeze the public config object
|
||||
this.frozenConfig = this.buildFrozenConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the immutable public configuration object.
|
||||
* This is what's exposed as Noodl.Config.
|
||||
*
|
||||
* @returns Frozen config object with all values
|
||||
*/
|
||||
getConfig(): Readonly<Record<string, unknown>> {
|
||||
if (!this.frozenConfig) {
|
||||
this.frozenConfig = this.buildFrozenConfig();
|
||||
}
|
||||
return this.frozenConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw configuration object (for editor use).
|
||||
* This includes the full structure with identity, seo, pwa, and variables.
|
||||
*
|
||||
* @returns The full app config object
|
||||
*/
|
||||
getRawConfig(): AppConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific variable definition by key.
|
||||
*
|
||||
* @param key - The variable key
|
||||
* @returns The variable definition or undefined if not found
|
||||
*/
|
||||
getVariable(key: string): ConfigVariable | undefined {
|
||||
return this.config.variables.find((v) => v.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all variable keys (for autocomplete/suggestions).
|
||||
*
|
||||
* @returns Array of all custom variable keys
|
||||
*/
|
||||
getVariableKeys(): string[] {
|
||||
return this.config.variables.map((v) => v.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the flat, frozen config object exposed as Noodl.Config.
|
||||
* This flattens identity, SEO, PWA, and custom variables into a single object.
|
||||
*
|
||||
* @returns The frozen config object
|
||||
*/
|
||||
private buildFrozenConfig(): Readonly<Record<string, unknown>> {
|
||||
const config: Record<string, unknown> = {
|
||||
// Identity fields
|
||||
appName: this.config.identity.appName,
|
||||
description: this.config.identity.description,
|
||||
coverImage: this.config.identity.coverImage,
|
||||
|
||||
// SEO fields (with smart defaults)
|
||||
ogTitle: this.config.seo.ogTitle || this.config.identity.appName,
|
||||
ogDescription: this.config.seo.ogDescription || this.config.identity.description,
|
||||
ogImage: this.config.seo.ogImage || this.config.identity.coverImage,
|
||||
favicon: this.config.seo.favicon,
|
||||
themeColor: this.config.seo.themeColor,
|
||||
|
||||
// PWA fields
|
||||
pwaEnabled: this.config.pwa?.enabled ?? false,
|
||||
pwaShortName: this.config.pwa?.shortName,
|
||||
pwaDisplay: this.config.pwa?.display,
|
||||
pwaStartUrl: this.config.pwa?.startUrl,
|
||||
pwaBackgroundColor: this.config.pwa?.backgroundColor
|
||||
};
|
||||
|
||||
// Add custom variables
|
||||
for (const variable of this.config.variables) {
|
||||
config[variable.key] = variable.value;
|
||||
}
|
||||
|
||||
// Deep freeze to prevent any mutation
|
||||
return deepFreeze(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the config manager (primarily for testing).
|
||||
* @internal
|
||||
*/
|
||||
reset(): void {
|
||||
this.config = DEFAULT_APP_CONFIG;
|
||||
this.frozenConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const configManager = ConfigManager.getInstance();
|
||||
|
||||
// Export class for testing
|
||||
export { ConfigManager };
|
||||
11
packages/noodl-runtime/src/config/index.ts
Normal file
11
packages/noodl-runtime/src/config/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* App Configuration Module
|
||||
*
|
||||
* Provides app-wide configuration accessible via Noodl.Config namespace.
|
||||
*
|
||||
* @module config
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './validation';
|
||||
export * from './config-manager';
|
||||
91
packages/noodl-runtime/src/config/types.ts
Normal file
91
packages/noodl-runtime/src/config/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* App Configuration Types
|
||||
*
|
||||
* Defines the structure for app-wide configuration values accessible via Noodl.Config.
|
||||
* Config values are static and immutable at runtime.
|
||||
*
|
||||
* @module config/types
|
||||
*/
|
||||
|
||||
export type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object';
|
||||
|
||||
export interface ConfigValidation {
|
||||
required?: boolean;
|
||||
pattern?: string; // Regex for strings
|
||||
min?: number; // For numbers
|
||||
max?: number; // For numbers
|
||||
}
|
||||
|
||||
export interface ConfigVariable {
|
||||
key: string;
|
||||
type: ConfigType;
|
||||
value: unknown;
|
||||
description?: string;
|
||||
category?: string;
|
||||
validation?: ConfigValidation;
|
||||
}
|
||||
|
||||
export interface AppIdentity {
|
||||
appName: string;
|
||||
description: string;
|
||||
coverImage?: string;
|
||||
}
|
||||
|
||||
export interface AppSEO {
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
favicon?: string;
|
||||
themeColor?: string;
|
||||
}
|
||||
|
||||
export interface AppPWA {
|
||||
enabled: boolean;
|
||||
shortName?: string;
|
||||
startUrl: string;
|
||||
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
|
||||
backgroundColor?: string;
|
||||
sourceIcon?: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
identity: AppIdentity;
|
||||
seo: AppSEO;
|
||||
pwa?: AppPWA;
|
||||
variables: ConfigVariable[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default app configuration used when no config is defined.
|
||||
*/
|
||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
identity: {
|
||||
appName: 'My Noodl App',
|
||||
description: ''
|
||||
},
|
||||
seo: {},
|
||||
variables: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Reserved configuration keys that cannot be used for custom variables.
|
||||
* These are populated from identity, SEO, and PWA settings.
|
||||
*/
|
||||
export const RESERVED_CONFIG_KEYS = [
|
||||
// Identity
|
||||
'appName',
|
||||
'description',
|
||||
'coverImage',
|
||||
// SEO
|
||||
'ogTitle',
|
||||
'ogDescription',
|
||||
'ogImage',
|
||||
'favicon',
|
||||
'themeColor',
|
||||
// PWA
|
||||
'pwaEnabled',
|
||||
'pwaShortName',
|
||||
'pwaDisplay',
|
||||
'pwaStartUrl',
|
||||
'pwaBackgroundColor'
|
||||
] as const;
|
||||
197
packages/noodl-runtime/src/config/validation.test.ts
Normal file
197
packages/noodl-runtime/src/config/validation.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Tests for Config Validation
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
|
||||
import { DEFAULT_APP_CONFIG } from './types';
|
||||
import { validateConfigKey, validateConfigValue, validateAppConfig } from './validation';
|
||||
|
||||
describe('validateConfigKey', () => {
|
||||
it('should accept valid JavaScript identifiers', () => {
|
||||
expect(validateConfigKey('apiKey').valid).toBe(true);
|
||||
expect(validateConfigKey('API_KEY').valid).toBe(true);
|
||||
expect(validateConfigKey('_privateKey').valid).toBe(true);
|
||||
expect(validateConfigKey('$special').valid).toBe(true);
|
||||
expect(validateConfigKey('key123').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty keys', () => {
|
||||
const result = validateConfigKey('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('cannot be empty');
|
||||
});
|
||||
|
||||
it('should reject invalid identifiers', () => {
|
||||
expect(validateConfigKey('123key').valid).toBe(false);
|
||||
expect(validateConfigKey('my-key').valid).toBe(false);
|
||||
expect(validateConfigKey('my key').valid).toBe(false);
|
||||
expect(validateConfigKey('my.key').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject reserved keys', () => {
|
||||
const result = validateConfigKey('appName');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('reserved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfigValue', () => {
|
||||
describe('number type', () => {
|
||||
it('should accept valid numbers', () => {
|
||||
expect(validateConfigValue(42, 'number').valid).toBe(true);
|
||||
expect(validateConfigValue(0, 'number').valid).toBe(true);
|
||||
expect(validateConfigValue(-10.5, 'number').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-numbers', () => {
|
||||
expect(validateConfigValue('42', 'number').valid).toBe(false);
|
||||
expect(validateConfigValue(NaN, 'number').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should enforce min/max validation', () => {
|
||||
expect(validateConfigValue(5, 'number', { min: 0, max: 10 }).valid).toBe(true);
|
||||
expect(validateConfigValue(-1, 'number', { min: 0 }).valid).toBe(false);
|
||||
expect(validateConfigValue(11, 'number', { max: 10 }).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string type', () => {
|
||||
it('should accept valid strings', () => {
|
||||
expect(validateConfigValue('hello', 'string').valid).toBe(true);
|
||||
expect(validateConfigValue('', 'string').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-strings', () => {
|
||||
expect(validateConfigValue(42, 'string').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should enforce pattern validation', () => {
|
||||
const result = validateConfigValue('abc', 'string', { pattern: '^[0-9]+$' });
|
||||
expect(result.valid).toBe(false);
|
||||
|
||||
const validResult = validateConfigValue('123', 'string', { pattern: '^[0-9]+$' });
|
||||
expect(validResult.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean type', () => {
|
||||
it('should accept booleans', () => {
|
||||
expect(validateConfigValue(true, 'boolean').valid).toBe(true);
|
||||
expect(validateConfigValue(false, 'boolean').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-booleans', () => {
|
||||
expect(validateConfigValue('true', 'boolean').valid).toBe(false);
|
||||
expect(validateConfigValue(1, 'boolean').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('color type', () => {
|
||||
it('should accept valid hex colors', () => {
|
||||
expect(validateConfigValue('#ff0000', 'color').valid).toBe(true);
|
||||
expect(validateConfigValue('#FF0000', 'color').valid).toBe(true);
|
||||
expect(validateConfigValue('#ff0000ff', 'color').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid colors', () => {
|
||||
expect(validateConfigValue('red', 'color').valid).toBe(false);
|
||||
expect(validateConfigValue('#ff00', 'color').valid).toBe(false);
|
||||
expect(validateConfigValue('ff0000', 'color').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('array type', () => {
|
||||
it('should accept arrays', () => {
|
||||
expect(validateConfigValue([], 'array').valid).toBe(true);
|
||||
expect(validateConfigValue([1, 2, 3], 'array').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-arrays', () => {
|
||||
expect(validateConfigValue('[]', 'array').valid).toBe(false);
|
||||
expect(validateConfigValue({}, 'array').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('object type', () => {
|
||||
it('should accept objects', () => {
|
||||
expect(validateConfigValue({}, 'object').valid).toBe(true);
|
||||
expect(validateConfigValue({ key: 'value' }, 'object').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-objects', () => {
|
||||
expect(validateConfigValue([], 'object').valid).toBe(false);
|
||||
expect(validateConfigValue(null, 'object').valid).toBe(false);
|
||||
expect(validateConfigValue('{}', 'object').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required validation', () => {
|
||||
it('should require non-empty values', () => {
|
||||
expect(validateConfigValue('', 'string', { required: true }).valid).toBe(false);
|
||||
expect(validateConfigValue(null, 'string', { required: true }).valid).toBe(false);
|
||||
expect(validateConfigValue(undefined, 'string', { required: true }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow empty values when not required', () => {
|
||||
expect(validateConfigValue('', 'string').valid).toBe(true);
|
||||
expect(validateConfigValue(null, 'string').valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAppConfig', () => {
|
||||
it('should accept valid config', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: {
|
||||
appName: 'Test App',
|
||||
description: 'Test Description'
|
||||
}
|
||||
};
|
||||
expect(validateAppConfig(config).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should require app name', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: {
|
||||
appName: '',
|
||||
description: ''
|
||||
}
|
||||
};
|
||||
const result = validateAppConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('App name');
|
||||
});
|
||||
|
||||
it('should detect duplicate variable keys', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: { appName: 'Test', description: '' },
|
||||
variables: [
|
||||
{ key: 'apiKey', type: 'string' as const, value: 'key1' },
|
||||
{ key: 'apiKey', type: 'string' as const, value: 'key2' }
|
||||
]
|
||||
};
|
||||
const result = validateAppConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('Duplicate'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate individual variables', () => {
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
identity: { appName: 'Test', description: '' },
|
||||
variables: [{ key: 'my-invalid-key', type: 'string' as const, value: 'test' }]
|
||||
};
|
||||
const result = validateAppConfig(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-object configs', () => {
|
||||
const result = validateAppConfig(null);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toContain('must be an object');
|
||||
});
|
||||
});
|
||||
179
packages/noodl-runtime/src/config/validation.ts
Normal file
179
packages/noodl-runtime/src/config/validation.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* App Configuration Validation
|
||||
*
|
||||
* Provides validation utilities for config keys and values.
|
||||
*
|
||||
* @module config/validation
|
||||
*/
|
||||
|
||||
import { ConfigType, ConfigValidation, RESERVED_CONFIG_KEYS } from './types';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a configuration variable key.
|
||||
* Keys must be valid JavaScript identifiers and not reserved.
|
||||
*
|
||||
* @param key - The key to validate
|
||||
* @returns Validation result with errors if invalid
|
||||
*/
|
||||
export function validateConfigKey(key: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!key || key.trim() === '') {
|
||||
errors.push('Key cannot be empty');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Must be valid JS identifier
|
||||
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
|
||||
errors.push('Key must be a valid JavaScript identifier (letters, numbers, _, $ only; cannot start with a number)');
|
||||
}
|
||||
|
||||
// Check reserved keys
|
||||
if ((RESERVED_CONFIG_KEYS as readonly string[]).includes(key)) {
|
||||
errors.push(`"${key}" is a reserved configuration key`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a configuration value based on its type and validation rules.
|
||||
*
|
||||
* @param value - The value to validate
|
||||
* @param type - The expected type
|
||||
* @param validation - Optional validation rules
|
||||
* @returns Validation result with errors if invalid
|
||||
*/
|
||||
export function validateConfigValue(value: unknown, type: ConfigType, validation?: ConfigValidation): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required check
|
||||
if (validation?.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push('This field is required');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// If value is empty and not required, skip type validation
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch (type) {
|
||||
case 'number':
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
errors.push('Value must be a number');
|
||||
} else {
|
||||
if (validation?.min !== undefined && value < validation.min) {
|
||||
errors.push(`Value must be at least ${validation.min}`);
|
||||
}
|
||||
if (validation?.max !== undefined && value > validation.max) {
|
||||
errors.push(`Value must be at most ${validation.max}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
if (typeof value !== 'string') {
|
||||
errors.push('Value must be a string');
|
||||
} else if (validation?.pattern) {
|
||||
try {
|
||||
const regex = new RegExp(validation.pattern);
|
||||
if (!regex.test(value)) {
|
||||
errors.push('Value does not match required pattern');
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('Invalid validation pattern');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
if (typeof value !== 'boolean') {
|
||||
errors.push('Value must be true or false');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'color':
|
||||
if (typeof value !== 'string') {
|
||||
errors.push('Color value must be a string');
|
||||
} else if (!/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)) {
|
||||
errors.push('Value must be a valid hex color (e.g., #ff0000 or #ff0000ff)');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push('Value must be an array');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
errors.push('Value must be an object');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errors.push(`Unknown type: ${type}`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an entire app config object.
|
||||
*
|
||||
* @param config - The app config to validate
|
||||
* @returns Validation result with all errors found
|
||||
*/
|
||||
export function validateAppConfig(config: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Type guard for config object
|
||||
if (!config || typeof config !== 'object') {
|
||||
errors.push('Config must be an object');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cfg = config as Record<string, any>;
|
||||
|
||||
// Check required identity fields
|
||||
if (!cfg.identity?.appName || cfg.identity.appName.trim() === '') {
|
||||
errors.push('App name is required');
|
||||
}
|
||||
|
||||
// Validate custom variables
|
||||
if (cfg.variables && Array.isArray(cfg.variables)) {
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
for (const variable of cfg.variables) {
|
||||
// Check for duplicate keys
|
||||
if (seenKeys.has(variable.key)) {
|
||||
errors.push(`Duplicate variable key: ${variable.key}`);
|
||||
continue;
|
||||
}
|
||||
seenKeys.add(variable.key);
|
||||
|
||||
// Validate key
|
||||
const keyValidation = validateConfigKey(variable.key);
|
||||
if (!keyValidation.valid) {
|
||||
errors.push(...keyValidation.errors.map((e) => `Variable "${variable.key}": ${e}`));
|
||||
}
|
||||
|
||||
// Validate value
|
||||
const valueValidation = validateConfigValue(variable.value, variable.type, variable.validation);
|
||||
if (!valueValidation.valid) {
|
||||
errors.push(...valueValidation.errors.map((e) => `Variable "${variable.key}": ${e}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
130
packages/noodl-viewer-react/src/api/config.ts
Normal file
130
packages/noodl-viewer-react/src/api/config.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Noodl.Config API
|
||||
*
|
||||
* Creates the immutable Noodl.Config proxy object that provides access
|
||||
* to app-wide configuration values defined in App Setup.
|
||||
*
|
||||
* @module api/config
|
||||
*/
|
||||
|
||||
import { AppConfig, DEFAULT_APP_CONFIG } from '@noodl/runtime/src/config/types';
|
||||
|
||||
/**
|
||||
* Builds the flat config object exposed as Noodl.Config.
|
||||
* This flattens identity, SEO, PWA, and custom variables into a single object.
|
||||
*/
|
||||
function buildFlatConfig(appConfig: Partial<AppConfig> | undefined): Record<string, unknown> {
|
||||
if (!appConfig) {
|
||||
appConfig = {};
|
||||
}
|
||||
|
||||
const config = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...appConfig,
|
||||
identity: {
|
||||
...DEFAULT_APP_CONFIG.identity,
|
||||
...appConfig.identity
|
||||
},
|
||||
seo: {
|
||||
...DEFAULT_APP_CONFIG.seo,
|
||||
...appConfig.seo
|
||||
},
|
||||
variables: appConfig.variables || []
|
||||
};
|
||||
|
||||
const flat: Record<string, unknown> = {
|
||||
// Identity fields
|
||||
appName: config.identity.appName,
|
||||
description: config.identity.description,
|
||||
coverImage: config.identity.coverImage,
|
||||
|
||||
// SEO fields (with smart defaults)
|
||||
ogTitle: config.seo.ogTitle || config.identity.appName,
|
||||
ogDescription: config.seo.ogDescription || config.identity.description,
|
||||
ogImage: config.seo.ogImage || config.identity.coverImage,
|
||||
favicon: config.seo.favicon,
|
||||
themeColor: config.seo.themeColor,
|
||||
|
||||
// PWA fields
|
||||
pwaEnabled: config.pwa?.enabled ?? false,
|
||||
pwaShortName: config.pwa?.shortName,
|
||||
pwaDisplay: config.pwa?.display,
|
||||
pwaStartUrl: config.pwa?.startUrl,
|
||||
pwaBackgroundColor: config.pwa?.backgroundColor
|
||||
};
|
||||
|
||||
// Add custom variables
|
||||
for (const variable of config.variables) {
|
||||
flat[variable.key] = variable.value;
|
||||
}
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Noodl.Config proxy object.
|
||||
* Returns an immutable object that throws helpful errors on write attempts.
|
||||
*
|
||||
* Uses lazy evaluation via getMetaData to handle async metadata loading.
|
||||
*
|
||||
* @param getMetaData - Function to get metadata by key (from noodlRuntime)
|
||||
* @returns Readonly proxy to the configuration object
|
||||
*/
|
||||
export function createConfigAPI(getMetaData: (key: string) => unknown): Readonly<Record<string, unknown>> {
|
||||
const getConfig = (): Record<string, unknown> => {
|
||||
// Rebuild config on each access to handle late-loaded metadata
|
||||
const appConfig = getMetaData('appConfig') as Partial<AppConfig> | undefined;
|
||||
return buildFlatConfig(appConfig);
|
||||
};
|
||||
|
||||
return new Proxy({} as Record<string, unknown>, {
|
||||
get(_target, prop: string | symbol) {
|
||||
// Handle special Proxy methods
|
||||
if (typeof prop === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (prop in config) {
|
||||
return config[prop];
|
||||
}
|
||||
console.warn(`Noodl.Config.${prop} is not defined`);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
has(_target, prop: string) {
|
||||
const config = getConfig();
|
||||
return prop in config;
|
||||
},
|
||||
|
||||
ownKeys() {
|
||||
const config = getConfig();
|
||||
return Reflect.ownKeys(config);
|
||||
},
|
||||
|
||||
getOwnPropertyDescriptor(_target, prop: string) {
|
||||
const config = getConfig();
|
||||
if (prop in config) {
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
value: config[prop]
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
set(_target, prop: string) {
|
||||
console.error(
|
||||
`Cannot set Noodl.Config.${prop} - Config values are immutable. ` +
|
||||
`Use Noodl.Variables for runtime-changeable values.`
|
||||
);
|
||||
return false;
|
||||
},
|
||||
|
||||
deleteProperty(_target, prop: string) {
|
||||
console.error(`Cannot delete Noodl.Config.${prop} - Config values are immutable.`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
import { createConfigAPI } from './api/config';
|
||||
import { SeoApi } from './api/seo';
|
||||
|
||||
export default function createNoodlAPI(noodlRuntime) {
|
||||
@@ -8,6 +9,7 @@ export default function createNoodlAPI(noodlRuntime) {
|
||||
|
||||
global.Noodl.getProjectSettings = noodlRuntime.getProjectSettings.bind(noodlRuntime);
|
||||
global.Noodl.getMetaData = noodlRuntime.getMetaData.bind(noodlRuntime);
|
||||
|
||||
global.Noodl.Collection = global.Noodl.Array = require('@noodl/runtime/src/collection');
|
||||
global.Noodl.Model = global.Noodl.Object = require('@noodl/runtime/src/model');
|
||||
global.Noodl.Variables = global.Noodl.Object.get('--ndl--global-variables');
|
||||
@@ -19,6 +21,7 @@ export default function createNoodlAPI(noodlRuntime) {
|
||||
global.Noodl.Navigation._noodlRuntime = noodlRuntime;
|
||||
global.Noodl.Files = require('./api/files');
|
||||
global.Noodl.SEO = new SeoApi();
|
||||
global.Noodl.Config = createConfigAPI(global.Noodl.getMetaData);
|
||||
if (!global.Noodl.Env) {
|
||||
global.Noodl.Env = {};
|
||||
}
|
||||
|
||||
13
packages/noodl-viewer-react/typings/global.d.ts
vendored
13
packages/noodl-viewer-react/typings/global.d.ts
vendored
@@ -28,6 +28,19 @@ type GlobalNoodl = {
|
||||
CloudFunctions: TSFixme;
|
||||
Navigation: TSFixme;
|
||||
Files: TSFixme;
|
||||
/**
|
||||
* App Configuration - Immutable configuration values defined in App Setup.
|
||||
* Access app-wide settings like API keys, feature flags, and metadata.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Access config values
|
||||
* const apiKey = Noodl.Config.apiKey;
|
||||
* const appName = Noodl.Config.appName;
|
||||
* const isFeatureEnabled = Noodl.Config.featureFlag;
|
||||
* ```
|
||||
*/
|
||||
Config: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
interface Window {
|
||||
|
||||
Reference in New Issue
Block a user