Fixed Logic Builder node bugs, expression field bugs, code editor bugs, property panel bugs

This commit is contained in:
Richard Osborne
2026-01-16 17:23:31 +01:00
parent 32a0a0885f
commit addd4d9c4a
28 changed files with 1969 additions and 29 deletions

View File

@@ -190,6 +190,7 @@ export function useSearchBar(
closeAllCategories();
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm]);
return setSearchTerm;

View File

@@ -4,6 +4,7 @@ import React, { ReactNode, useEffect, useState } from 'react';
import { NodeType } from '@noodl-constants/NodeType';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
@@ -83,12 +84,13 @@ export default function NodePickerCategory({
</Text>
</Collapsible>
<img
className={classNames([
<Icon
icon={IconName.CaretRight}
size={IconSize.Small}
UNSAFE_className={classNames([
css['Arrow'],
isCollapsedState ? css['Arrow--is-collapsed'] : css['Arrow--is-not-collapsed']
])}
src="/assets/icons/editor/right_arrow_22.svg"
/>
</header>

View File

@@ -9,6 +9,7 @@ import { createNodeIndex } from '@noodl-utils/createnodeindex';
import { tracker } from '@noodl-utils/tracker';
import { HtmlRenderer } from '@noodl-core-ui/components/common/HtmlRenderer';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { SearchInput } from '@noodl-core-ui/components/inputs/SearchInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
@@ -180,7 +181,7 @@ export function NodeLibrary({ model, parentModel, pos, attachToRoot, runtimeType
createNewComment(model, pos);
e.stopPropagation();
}}
icon={<img src="/assets/icons/comment.svg" />}
icon={<Icon icon={IconName.Chat} size={IconSize.Default} />}
/>
</NodePickerSection>
) : null}

View File

@@ -183,6 +183,7 @@ export class NodeGraphEditor extends View {
curtop = 0;
inspectorsModel: DebugInspector.InspectorsModel;
clearDeleteModeTimer: NodeJS.Timeout;
lastBlocklyTabCloseTime: number = 0; // Track when Blockly tabs close to prevent accidental deletions
draggingNodes: NodeGraphEditorNode[] | null = null;
@@ -321,6 +322,8 @@ export class NodeGraphEditor extends View {
'LogicBuilder.AllTabsClosed',
() => {
console.log('[NodeGraphEditor] All Logic Builder tabs closed - showing canvas');
// Track close time to prevent accidental node deletions during focus transition
this.lastBlocklyTabCloseTime = Date.now();
this.setCanvasVisibility(true);
},
this
@@ -393,27 +396,29 @@ export class NodeGraphEditor extends View {
// Load icons using webpack require to ensure proper bundling
this.homeIcon = new Image();
this.homeIcon.src = require('../../../assets/icons/core-ui-temp/home--nodegraph.svg');
this.homeIcon.src = require('../../../assets/icons/core-ui-temp/home--nodegraph.svg').default;
this.homeIcon.onload = () => this.repaint();
this.homeIcon.onerror = (e) => console.error('Failed to load home icon:', e);
this.componentIcon = new Image();
this.componentIcon.src = require('../../../assets/icons/core-ui-temp/component--nodegraph.svg');
this.componentIcon.src = require('../../../assets/icons/core-ui-temp/component--nodegraph.svg').default;
this.componentIcon.onload = () => this.repaint();
this.componentIcon.onerror = (e) => console.error('Failed to load component icon:', e);
this.aiAssistantInnerIcon = new Image();
this.aiAssistantInnerIcon.src = require('../../../assets/icons/core-ui-temp/aiAssistant--nodegraph-inner.svg');
this.aiAssistantInnerIcon.src =
require('../../../assets/icons/core-ui-temp/aiAssistant--nodegraph-inner.svg').default;
this.aiAssistantInnerIcon.onload = () => this.repaint();
this.aiAssistantInnerIcon.onerror = (e) => console.error('Failed to load AI assistant inner icon:', e);
this.aiAssistantOuterIcon = new Image();
this.aiAssistantOuterIcon.src = require('../../../assets/icons/core-ui-temp/aiAssistant--nodegraph-outer.svg');
this.aiAssistantOuterIcon.src =
require('../../../assets/icons/core-ui-temp/aiAssistant--nodegraph-outer.svg').default;
this.aiAssistantOuterIcon.onload = () => this.repaint();
this.aiAssistantOuterIcon.onerror = (e) => console.error('Failed to load AI assistant outer icon:', e);
this.warningIcon = new Image();
this.warningIcon.src = require('../../../assets/icons/core-ui-temp/warning_triangle.svg');
this.warningIcon.src = require('../../../assets/icons/core-ui-temp/warning_triangle.svg').default;
this.warningIcon.onload = () => this.repaint();
this.warningIcon.onerror = (e) => console.error('Failed to load warning icon:', e);
@@ -1179,6 +1184,14 @@ export class NodeGraphEditor extends View {
return false;
}
// Guard against accidental deletions during Blockly tab close transition
// This prevents nodes from being deleted if a Blockly tab was just closed
const timeSinceBlocklyClose = Date.now() - this.lastBlocklyTabCloseTime;
if (timeSinceBlocklyClose < 200) {
console.warn('[NodeGraphEditor] Ignoring delete during Blockly tab close transition');
return false;
}
const nodes = [...this.selector.nodes];
// Make sure all nodes can be deleted

View File

@@ -196,8 +196,20 @@ export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
});
}
} else {
// It's a folder
setSelectedId(node.data.path);
// Toggle folder if clicking on folder
// BUG-6 FIX: If it's a component-folder, open the component too
// Component-folders are folders that also have an associated component
// (e.g., App with App/Header as a child)
if (node.data.isComponentFolder && node.data.component) {
EventDispatcher.instance.notifyListeners('ComponentPanel.SwitchToComponent', {
component: node.data.component,
pushHistory: true
});
}
// Toggle folder expand/collapse
toggleFolder(node.data.path);
}
},

View File

@@ -337,6 +337,11 @@ export class CodeEditorType extends TypeView {
nodeId: nodeId
});
// Create close handler to trigger popout close
const closeHandler = () => {
_this.parent.hidePopout();
};
// Render JavaScriptEditor with proper sizing and history support
// For read-only fields, don't pass nodeId/parameterName (no history tracking)
this.popoutRoot.render(
@@ -350,6 +355,7 @@ export class CodeEditorType extends TypeView {
onSave: () => {
save();
},
onClose: closeHandler,
validationType,
disabled: this.readOnly, // Enable read-only mode if port is marked readOnly
width: props.initialSize?.x || 800,

View File

@@ -0,0 +1,43 @@
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
/**
* Hidden editor type for internal Logic Builder parameters
* Renders nothing - used for parameters that need to be stored
* but should not appear in the property panel
*/
export class LogicBuilderHiddenType extends TypeView {
el: TSFixme;
static fromPort(args) {
const view = new LogicBuilderHiddenType();
const p = args.port;
const parent = args.parent;
view.port = p;
view.displayName = p.displayName ? p.displayName : p.name;
view.name = p.name;
view.type = getEditType(p);
view.group = null; // No group
view.tooltip = p.tooltip;
view.value = parent.model.getParameter(p.name);
view.parent = parent;
view.isConnected = parent.model.isPortConnected(p.name, 'target');
view.isDefault = parent.model.parameters[p.name] === undefined;
return view;
}
render() {
// Render an empty, invisible element
// This is necessary because the property panel expects something to be returned
// but we want it to take up no space
this.el = $('<div style="display: none;"></div>');
return this.el;
}
dispose() {
// Nothing to clean up
}
}

View File

@@ -1,4 +1,8 @@
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
import { GeneratedCodeModal } from '../GeneratedCodeModal';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
@@ -10,6 +14,10 @@ import { getEditType } from '../utils';
export class LogicBuilderWorkspaceType extends TypeView {
el: TSFixme;
editButton: JQuery;
viewCodeButton: JQuery;
modalContainer: HTMLDivElement | null = null;
modalRoot: Root | null = null;
isModalOpen: boolean = false;
static fromPort(args) {
const view = new LogicBuilderWorkspaceType();
@@ -42,7 +50,7 @@ export class LogicBuilderWorkspaceType extends TypeView {
</style>
`;
// Create a simple container with single button
// Create a simple container with two buttons
const html =
hideEmptyGroupsCSS +
`
@@ -61,21 +69,42 @@ export class LogicBuilderWorkspaceType extends TypeView {
"
onmouseover="this.style.backgroundColor='var(--theme-color-primary-hover)'"
onmouseout="this.style.backgroundColor='var(--theme-color-primary)'">
View Logic Blocks
Edit Logic Blocks
</button>
<button class="view-code-button"
style="
padding: 8px 16px;
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
"
onmouseover="this.style.backgroundColor='var(--theme-color-bg-4)'"
onmouseout="this.style.backgroundColor='var(--theme-color-bg-3)'">
View Generated Code
</button>
</div>
`;
this.el = this.bindView($(html), this);
// Get reference to button
// Get references to buttons
this.editButton = this.el.find('.edit-blocks-button');
this.viewCodeButton = this.el.find('.view-code-button');
// Handle button click
// Handle button clicks
this.editButton.on('click', () => {
this.onEditBlocksClicked();
});
this.viewCodeButton.on('click', () => {
this.onViewCodeClicked();
});
// Call parent render for common functionality (tooltips, etc.)
TypeView.prototype.render.call(this);
@@ -101,6 +130,46 @@ export class LogicBuilderWorkspaceType extends TypeView {
});
}
onViewCodeClicked() {
const nodeName = this.parent?.model?.model?.label || this.parent?.model?.type?.displayName || 'Logic Builder';
const generatedCode = this.parent?.model?.getParameter('generatedCode') || '';
console.log('[LogicBuilderWorkspaceType] Opening generated code modal for node:', nodeName);
this.showModal(nodeName, generatedCode);
}
showModal(nodeName: string, code: string) {
// Create modal container if it doesn't exist
if (!this.modalContainer) {
this.modalContainer = document.createElement('div');
this.modalContainer.id = 'generated-code-modal-container';
document.body.appendChild(this.modalContainer);
this.modalRoot = createRoot(this.modalContainer);
}
this.isModalOpen = true;
this.renderModal(nodeName, code);
}
hideModal() {
this.isModalOpen = false;
this.renderModal('', '');
}
renderModal(nodeName: string, code: string) {
if (!this.modalRoot) return;
this.modalRoot.render(
React.createElement(GeneratedCodeModal, {
isOpen: this.isModalOpen,
nodeName: nodeName,
code: code,
onClose: () => this.hideModal()
})
);
}
updateChangedDot() {
const dot = this.el.find('.property-changed-dot');
if (this.isDefault) {
@@ -119,4 +188,16 @@ export class LogicBuilderWorkspaceType extends TypeView {
this.isDefault = true;
this.updateChangedDot();
}
dispose() {
// Clean up modal when view is disposed
if (this.modalRoot) {
this.modalRoot.unmount();
this.modalRoot = null;
}
if (this.modalContainer && this.modalContainer.parentNode) {
this.modalContainer.parentNode.removeChild(this.modalContainer);
this.modalContainer = null;
}
}
}

View File

@@ -21,6 +21,7 @@ import { FontType } from './FontType';
import { IconType } from './IconType';
import { IdentifierType } from './IdentifierType';
import { ImageType } from './ImageType';
import { LogicBuilderHiddenType } from './LogicBuilderHiddenType';
import { LogicBuilderWorkspaceType } from './LogicBuilderWorkspaceType';
import { MarginPaddingType } from './MarginPaddingType';
import { NumberWithUnits } from './NumberWithUnits';
@@ -226,6 +227,11 @@ export class Ports extends View {
return LogicBuilderWorkspaceType;
}
// Hidden type for internal Logic Builder parameters (renders nothing)
if (typeof type === 'object' && type.editorType === 'logic-builder-hidden') {
return LogicBuilderHiddenType;
}
// Align tools types
function isOfAlignToolsType() {
return NodeLibrary.nameForPortType(type) === 'enum' && typeof type === 'object' && type.alignComp !== undefined;

View File

@@ -0,0 +1,83 @@
.Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.Modal {
background-color: var(--theme-color-bg-2, #1a1a1a);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 800px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.Header {
padding: 16px 20px;
border-bottom: 1px solid var(--theme-color-border-default, rgba(255, 255, 255, 0.1));
display: flex;
align-items: center;
justify-content: space-between;
}
.Title {
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-highlight, #ffffff);
margin: 0;
}
.ReadOnlyBadge {
font-size: 11px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
background-color: var(--theme-color-bg-3, rgba(255, 255, 255, 0.1));
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.6));
}
.InfoBar {
padding: 8px 20px;
background-color: var(--theme-color-bg-3, rgba(255, 255, 255, 0.05));
border-bottom: 1px solid var(--theme-color-border-default, rgba(255, 255, 255, 0.1));
}
.InfoText {
font-size: 12px;
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.6));
}
.Body {
padding: 16px 20px;
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.EditorWrapper {
flex: 1;
min-height: 400px;
border-radius: 4px;
overflow: hidden;
}
.Footer {
padding: 12px 20px;
border-top: 1px solid var(--theme-color-border-default, rgba(255, 255, 255, 0.1));
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,121 @@
/**
* GeneratedCodeModal
*
* A read-only modal for viewing generated code from Logic Builder.
* Uses the CodeMirror-based JavaScriptEditor in read-only mode.
*/
import React, { useCallback, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { JavaScriptEditor } from '@noodl-core-ui/components/code-editor';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import css from './GeneratedCodeModal.module.scss';
export interface GeneratedCodeModalProps {
/** Whether the modal is open */
isOpen: boolean;
/** The node name for display */
nodeName: string;
/** The generated code to display */
code: string;
/** Called when modal is closed */
onClose: () => void;
}
/**
* Read-only modal for viewing generated JavaScript code
*/
export function GeneratedCodeModal({ isOpen, nodeName, code, onClose }: GeneratedCodeModalProps) {
const codeRef = useRef<string>(code);
// Keep code ref updated
useEffect(() => {
codeRef.current = code;
}, [code]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'Escape') {
onClose();
}
},
[isOpen, onClose]
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// Handle copy to clipboard
const handleCopy = useCallback(() => {
if (codeRef.current) {
navigator.clipboard.writeText(codeRef.current).then(
() => {
console.log('[GeneratedCodeModal] Code copied to clipboard');
},
(err) => {
console.error('[GeneratedCodeModal] Failed to copy code:', err);
}
);
}
}, []);
if (!isOpen) return null;
const displayCode =
code || '// No code generated yet.\n// Add some blocks in the Logic Builder and close the editor.';
// Render into portal to escape any z-index issues
return ReactDOM.createPortal(
<div className={css['Overlay']} onClick={onClose}>
<div className={css['Modal']} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className={css['Header']}>
<span className={css['Title']}>Generated Code: {nodeName}</span>
<span className={css['ReadOnlyBadge']}>Read-Only</span>
</div>
{/* Info */}
<div className={css['InfoBar']}>
<span className={css['InfoText']}>
This is the JavaScript generated from your logic blocks. You can copy it but not edit it directly.
</span>
</div>
{/* Body with editor */}
<div className={css['Body']}>
<div className={css['EditorWrapper']}>
<JavaScriptEditor
value={displayCode}
onChange={() => {}} // No-op since read-only
validationType="script"
height={400}
width={700}
disabled={true}
/>
</div>
</div>
{/* Footer with buttons */}
<div className={css['Footer']}>
<PrimaryButton
label="Copy Code"
variant={PrimaryButtonVariant.Muted}
icon={IconName.Copy}
onClick={handleCopy}
isDisabled={!code}
/>
<PrimaryButton label="Close" variant={PrimaryButtonVariant.Cta} onClick={onClose} />
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1 @@
export { GeneratedCodeModal, type GeneratedCodeModalProps } from './GeneratedCodeModal';