From b9ea0a376780957baa449463506074eba652fc43 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Fri, 12 Apr 2024 11:11:54 -0700 Subject: [PATCH 01/18] Begin FB-05 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 190fb95..38079df 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ The backlog is organized by epic, with each task having a unique ID, description | To Do | In Progress | In Review | Done | | ----- | ----------- | --------- | ----- | -| FB-05 | | | FB-01 | +| | FB-05 | | FB-01 | | | | | FB-02 | | | | | FB-03 | | | | | FB-04 | From 91c5156afd0935873ffc0ff25ed9e86b6c59dd79 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Fri, 12 Apr 2024 11:33:44 -0700 Subject: [PATCH 02/18] Move overriden methods first in engine.ts --- .../src/app/components/flow-canvas/engine.ts | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/flow-client/src/app/components/flow-canvas/engine.ts b/packages/flow-client/src/app/components/flow-canvas/engine.ts index 305c4b2..83eab1e 100644 --- a/packages/flow-client/src/app/components/flow-canvas/engine.ts +++ b/packages/flow-client/src/app/components/flow-canvas/engine.ts @@ -48,34 +48,6 @@ export class CustomEngine extends DiagramEngine { }); } - public increaseZoomLevel(event: WheelEvent): void { - const model = this.getModel(); - if (model) { - const zoomFactor = 0.1; // Adjust this value based on your needs - const oldZoomLevel = model.getZoomLevel(); - const newZoomLevel = Math.max( - oldZoomLevel + event.deltaY * -zoomFactor, - 50 - ); - - // Calculate the new offset - const boundingRect = ( - event.currentTarget as Element - )?.getBoundingClientRect(); - const clientX = event.clientX - boundingRect.left; - const clientY = event.clientY - boundingRect.top; - const deltaX = clientX - model.getOffsetX(); - const deltaY = clientY - model.getOffsetY(); - const zoomRatio = newZoomLevel / oldZoomLevel; - const newOffsetX = clientX - deltaX * zoomRatio; - const newOffsetY = clientY - deltaY * zoomRatio; - - model.setZoomLevel(newZoomLevel); - model.setOffset(newOffsetX, newOffsetY); - this.repaintCanvas(); - } - } - public override setModel(model: CustomDiagramModel): void { const ret = super.setModel(model); @@ -105,6 +77,34 @@ export class CustomEngine extends DiagramEngine { return ret; } + + public increaseZoomLevel(event: WheelEvent): void { + const model = this.getModel(); + if (model) { + const zoomFactor = 0.1; // Adjust this value based on your needs + const oldZoomLevel = model.getZoomLevel(); + const newZoomLevel = Math.max( + oldZoomLevel + event.deltaY * -zoomFactor, + 50 + ); + + // Calculate the new offset + const boundingRect = ( + event.currentTarget as Element + )?.getBoundingClientRect(); + const clientX = event.clientX - boundingRect.left; + const clientY = event.clientY - boundingRect.top; + const deltaX = clientX - model.getOffsetX(); + const deltaY = clientY - model.getOffsetY(); + const zoomRatio = newZoomLevel / oldZoomLevel; + const newOffsetX = clientX - deltaX * zoomRatio; + const newOffsetY = clientY - deltaY * zoomRatio; + + model.setZoomLevel(newZoomLevel); + model.setOffset(newOffsetX, newOffsetY); + this.repaintCanvas(); + } + } } export const createEngine = (options = {}) => { From ac3c66906d547e295c7c59c090b7ec6142e07f19 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Fri, 12 Apr 2024 11:57:04 -0700 Subject: [PATCH 03/18] Move node drop calculations from flow-canvas to engine --- .../src/app/components/flow-canvas/engine.ts | 50 ++++++++++++++++- .../components/flow-canvas/flow-canvas.tsx | 55 ++++++------------- 2 files changed, 65 insertions(+), 40 deletions(-) diff --git a/packages/flow-client/src/app/components/flow-canvas/engine.ts b/packages/flow-client/src/app/components/flow-canvas/engine.ts index 83eab1e..e22ca7a 100644 --- a/packages/flow-client/src/app/components/flow-canvas/engine.ts +++ b/packages/flow-client/src/app/components/flow-canvas/engine.ts @@ -1,3 +1,4 @@ +import { Point } from '@projectstorm/geometry'; import { AbstractReactFactory, BaseEvent, @@ -14,9 +15,10 @@ import { PathFindingLinkFactory, SelectionBoxLayerFactory, } from '@projectstorm/react-diagrams'; +import { DropTargetMonitor } from 'react-dnd'; -import { CustomNodeFactory } from './node'; import { CustomDiagramModel } from './model'; +import { CustomNodeFactory } from './node'; export class CustomEngine extends DiagramEngine { constructor(options?: CanvasEngineOptions) { @@ -105,6 +107,52 @@ export class CustomEngine extends DiagramEngine { this.repaintCanvas(); } } + + public calculateDropPosition( + monitor: DropTargetMonitor, + canvas: HTMLCanvasElement + ): Point { + // Get the monitor's client offset + const monitorOffset = monitor.getClientOffset(); + + // Get the initial client offset (cursor position at drag start) + const initialClientOffset = monitor.getInitialClientOffset(); + + // Get the initial source client offset (dragged item's position at drag start) + const initialSourceClientOffset = + monitor.getInitialSourceClientOffset(); + + if ( + !monitorOffset || + !initialClientOffset || + !initialSourceClientOffset + ) { + throw new Error( + `Unable to get monitor offsets: ${monitorOffset}, ${initialClientOffset}, ${initialSourceClientOffset}` + ); + } + + // Get the current zoom level from the engine's model + const zoomLevel = this.getModel().getZoomLevel() / 100; // Convert to decimal + + // Calculate the cursor's offset within the dragged item + const cursorOffsetX = + initialClientOffset.x - initialSourceClientOffset.x; + const cursorOffsetY = + initialClientOffset.y - initialSourceClientOffset.y; + + // Get the bounding rectangle of the canvas widget + const canvasRect = canvas.getBoundingClientRect(); + + // Calculate the correct position by subtracting the canvas's top and left offsets + const canvasOffsetX = (monitorOffset.x - canvasRect.left) / zoomLevel; + const canvasOffsetY = (monitorOffset.y - canvasRect.top) / zoomLevel; + + const correctedX = canvasOffsetX - cursorOffsetX; + const correctedY = canvasOffsetY - cursorOffsetY; + + return new Point(correctedX, correctedY); + } } export const createEngine = (options = {}) => { diff --git a/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx b/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx index ff681c0..06ecacf 100644 --- a/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx +++ b/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx @@ -1,3 +1,4 @@ +import { Point } from '@projectstorm/geometry'; import { CanvasWidget, ListenerHandle } from '@projectstorm/react-canvas-core'; import { DefaultLinkModel, @@ -167,58 +168,34 @@ export const FlowCanvas: React.FC = ({ const [, drop] = useDrop(() => ({ accept: ItemTypes.NODE, drop: (entity: NodeEntity, monitor) => { - // Get the monitor's client offset - const monitorOffset = monitor.getClientOffset(); - - // Get the initial client offset (cursor position at drag start) - const initialClientOffset = monitor.getInitialClientOffset(); - - // Get the initial source client offset (dragged item's position at drag start) - const initialSourceClientOffset = - monitor.getInitialSourceClientOffset(); - - // Get the current zoom level from the engine's model - const zoomLevel = engine.getModel().getZoomLevel() / 100; // Convert to decimal - - if ( - !monitorOffset || - !initialClientOffset || - !initialSourceClientOffset - ) { - return; - } - - // Calculate the cursor's offset within the dragged item - const cursorOffsetX = - initialClientOffset.x - initialSourceClientOffset.x; - const cursorOffsetY = - initialClientOffset.y - initialSourceClientOffset.y; - // Find the canvas widget element - const canvasElement = document.querySelector('.flow-canvas > svg'); + const canvasElement = document.querySelector( + '.flow-canvas > svg' + ) as HTMLCanvasElement; if (!canvasElement) { + console.error('Error finding canvas element'); return; } - // Get the bounding rectangle of the canvas widget - const canvasRect = canvasElement.getBoundingClientRect(); - - // Calculate the correct position by subtracting the canvas's top and left offsets - const canvasOffsetX = - (monitorOffset.x - canvasRect.left) / zoomLevel; - const canvasOffsetY = - (monitorOffset.y - canvasRect.top) / zoomLevel; + let nodePosition: Point; - const correctedX = canvasOffsetX - cursorOffsetX; - const correctedY = canvasOffsetY - cursorOffsetY; + try { + nodePosition = engine.calculateDropPosition( + monitor, + canvasElement + ); + } catch (error) { + console.error('Error calculating drop position:', error); + return; + } const node = new CustomNodeModel(entity, { name: entity.type, color: entity.color, }); - node.setPosition(correctedX, correctedY); + node.setPosition(nodePosition); const ports = nodeLogic.getNodeInputsOutputs(entity); ports.inputs.forEach(input => { From 95d0cb1d4a08856b64765ac7a15133ced63fc7ae Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Wed, 17 Apr 2024 11:39:23 -0700 Subject: [PATCH 04/18] Make connection between react-diagram and flow.slice state two-way --- .../components/flow-canvas/flow-canvas.tsx | 23 +++ .../src/app/components/flow-canvas/model.ts | 19 ++ .../src/app/components/flow-canvas/node.tsx | 6 +- .../src/app/redux/modules/flow/flow.logic.ts | 188 +++++++++++++----- .../src/app/redux/modules/flow/flow.slice.ts | 51 +++++ 5 files changed, 238 insertions(+), 49 deletions(-) diff --git a/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx b/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx index 06ecacf..0720ac2 100644 --- a/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx +++ b/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx @@ -102,6 +102,10 @@ export const FlowCanvas: React.FC = ({ const [model] = useState(new CustomDiagramModel()); + const serializedGraph = useAppSelector(state => + flowLogic.selectSerializedGraphByFlowId(state, model.getID()) + ); + // Inside your component const listenerHandleRef = useRef(null); @@ -117,6 +121,22 @@ export const FlowCanvas: React.FC = ({ }, [initialDiagram.links, initialDiagram.nodes, model]); useEffect(() => { + // we deserialize our graph in the same selector so that it doesn't trigger event handlers + if (serializedGraph) { + // don't overwrite some properties + serializedGraph.offsetX = model.getOffsetX() ?? 0; + serializedGraph.offsetY = model.getOffsetY() ?? 0; + serializedGraph.zoom = model.getZoomLevel() ?? 1; + serializedGraph.gridSize = model.getOptions().gridSize ?? 20; + // order our layers: links, nodes + serializedGraph.layers.sort((a, b) => { + if (a.type === 'diagram-links') return -1; + if (b.type === 'diagram-links') return 1; + return 0; + }); + // model.deserializeModel(serializedGraph, engine); + // engine.repaintCanvas(); + } // Event listener for any change in the model const handleModelChange = debounce(() => { // Serialize the current state of the diagram @@ -132,6 +152,8 @@ export const FlowCanvas: React.FC = ({ 'CustomDiagramModel:nodesUpdated': handleModelChange, 'CustomNodeModel:positionChanged': handleModelChange, 'DefaultLinkModel:targetPortChanged': handleModelChange, + 'DefaultLinkModel:pointsUpdated': handleModelChange, + 'PointModel:positionChanged': handleModelChange, // Add more listeners as needed }); @@ -148,6 +170,7 @@ export const FlowCanvas: React.FC = ({ initialDiagram.links, initialDiagram.nodes, model, + serializedGraph, ]); useEffect(() => { diff --git a/packages/flow-client/src/app/components/flow-canvas/model.ts b/packages/flow-client/src/app/components/flow-canvas/model.ts index 638b690..566ec74 100644 --- a/packages/flow-client/src/app/components/flow-canvas/model.ts +++ b/packages/flow-client/src/app/components/flow-canvas/model.ts @@ -4,6 +4,7 @@ import { LinkModel, BaseEvent, PortModel, + PointModel, } from '@projectstorm/react-diagrams'; export class CustomDiagramModel extends DiagramModel { @@ -32,6 +33,24 @@ export class CustomDiagramModel extends DiagramModel { // Custom method to add a link and register an event listener override addLink(link: LinkModel): LinkModel { const ret = super.addLink(link); + // intercept points + const linkAddPoint = link.addPoint.bind(link); + link.addPoint =

(point: P) => { + const ret = linkAddPoint(point); + point.registerListener({ + eventDidFire: (e: BaseEvent) => { + this.fireEvent(e, '_globalPassthrough'); + }, + }); + link.fireEvent( + { + link, + isCreated: true, + }, + 'pointsUpdated' + ); + return ret; + }; // Register an event listener for the link link.registerListener({ eventDidFire: (e: BaseEvent) => { diff --git a/packages/flow-client/src/app/components/flow-canvas/node.tsx b/packages/flow-client/src/app/components/flow-canvas/node.tsx index b0bf764..ec26a8a 100644 --- a/packages/flow-client/src/app/components/flow-canvas/node.tsx +++ b/packages/flow-client/src/app/components/flow-canvas/node.tsx @@ -187,7 +187,9 @@ export class CustomNodeFactory extends AbstractReactFactory< } generateModel(_event: GenerateModelEvent) { - throw new Error('Not implemented'); - return undefined as unknown as CustomNodeModel; + return new CustomNodeModel( + _event.initialConfig.extras.entity, + _event.initialConfig + ); } } diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts index cb476c4..1fb00d9 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts @@ -1,9 +1,14 @@ +import { createSelector } from '@reduxjs/toolkit'; + import { AppDispatch, RootState } from '../../store'; -import { NodeEntity } from '../node/node.slice'; +import { NodeEntity, selectAllNodes } from '../node/node.slice'; import { FlowEntity, FlowNodeEntity, + LinkModel, + PortModel, flowActions, + selectEntityById, selectFlowNodesByFlowId, } from './flow.slice'; @@ -14,12 +19,16 @@ export type SerializedGraph = { zoom: number; gridSize: number; layers: Layer[]; + locked: boolean; }; type BaseLayer = { id: string; isSvg: boolean; transformed: boolean; + selected: boolean; + extras: Record; + locked: boolean; }; export type NodeLayer = BaseLayer & { @@ -44,55 +53,14 @@ export type NodeModel = { color: string; portsInOrder?: string[]; portsOutOrder?: string[]; + locked: boolean; + selected: boolean; extras: { entity: NodeEntity; [key: string]: unknown; }; }; -export type LinkModel = { - id: string; - type: string; - source: string; - sourcePort: string; - target: string; - targetPort: string; - points: PointModel[]; - labels: LabelModel[]; - width: number; - color: string; - curvyness: number; - selectedColor: string; -}; - -export type PortModel = { - id: string; - type: string; - x: number; - y: number; - name: string; - alignment: string; - parentNode: string; - links: string[]; - in: boolean; - label: string; -}; - -export type PointModel = { - id: string; - type: string; - x: number; - y: number; -}; - -export type LabelModel = { - id: string; - type: string; - offsetX: number; - offsetY: number; - label: string; -}; - export class FlowLogic { // Method to convert and update the flow based on the serialized graph from react-diagrams updateFlowFromSerializedGraph(graph: SerializedGraph) { @@ -159,11 +127,18 @@ export class FlowLogic { const nodes = Object.values(nodeModels).map( (node): FlowNodeEntity => { // For each port of the node - const wires = node.ports + const wires: string[][] = []; + const outLinks: Record = {}; + node.ports // only look at out ports .filter(it => !it.in) //find connected target ports and map them to target node IDs - .map(port => linksBySourcePort[port.id] || []); + .forEach(port => { + wires.push(linksBySourcePort[port.id] || []); + port.links.forEach(linkId => { + outLinks[linkId] = linkModels[linkId]; + }); + }); return { id: node.id, @@ -172,8 +147,10 @@ export class FlowLogic { y: node.y, z: graph.id, // Assuming all nodes belong to the same flow name: node.name, - wires: wires.length > 0 ? wires : [], // Adjust based on your wires structure needs + wires, // Add other properties as needed + ports: node.ports, + links: outLinks, }; } ); @@ -182,4 +159,121 @@ export class FlowLogic { dispatch(flowActions.upsertEntities(nodes)); }; } + + selectSerializedGraphByFlowId = createSelector( + [state => state, selectEntityById, selectFlowNodesByFlowId], + (state, flow, flowNodes) => { + if (!flow) { + return null; + } + + const nodeEntities = Object.fromEntries( + selectAllNodes(state).map(it => [it.id, it]) + ); + + // Construct NodeModels from flow nodes + const nodeModels: { [key: string]: NodeModel } = {}; + // Infer LinkModels from node wires + const linkModels: { [key: string]: LinkModel } = {}; + + flowNodes.forEach(node => { + const outPorts = node.ports?.filter(it => !it.in) ?? []; + + node.wires?.forEach((portWires, index) => { + const port = outPorts[index]; + + // if (!port) { + // port = { + // id: `${node.id}-out-${index}`, + // type: 'default', + // x: 0, + // y: 0, + // name: '', + // alignment: 'right', + // parentNode: node.id, + // links: [], + // in: false, + // label: `Output ${index + 1}`, + // }; + // outPorts.push(port); + // } + + portWires.forEach((targetNodeId, targetIndex) => { + const linkId = port.links[targetIndex]; + const nodeLink = node.links?.[linkId]; + if (nodeLink) { + linkModels[linkId] = nodeLink; + } + + // linkModels[linkId] = { + // id: linkId, + // type: 'default', // Placeholder value + // source: node.id, + // sourcePort: port.id, // Assuming a port naming convention + // target: targetNodeId, + // targetPort: `${targetNodeId}-in-${targetIndex}`, // This assumes you can determine the target port index + // points: [], // Points would need to be constructed if they are used + // labels: [], // Labels would need to be constructed if they are used + // width: 1, // Placeholder value + // color: 'defaultColor', // Placeholder value + // curvyness: 0, // Placeholder value + // selectedColor: 'defaultSelectedColor', // Placeholder value + // locked: false, + // selected: false, + // extras: {}, + // }; + }); + }); + + nodeModels[node.id] = { + id: node.id, + type: 'custom-node', + x: node.x, + y: node.y, + ports: node.ports ?? [], + name: node.name || '', + color: 'defaultColor', + locked: false, + selected: false, + extras: { + entity: nodeEntities[node.type], + }, + }; + }); + + // Assemble the SerializedGraph + const serializedGraph: SerializedGraph = { + id: flow.id, + offsetX: 0, // Placeholder value + offsetY: 0, // Placeholder value + zoom: 1, // Placeholder value + gridSize: 20, // Placeholder value + locked: false, + layers: [ + { + id: 'nodeLayer', + isSvg: false, + transformed: true, + type: 'diagram-nodes', + models: nodeModels, + locked: false, + selected: false, + extras: {}, + }, + { + id: 'linkLayer', + isSvg: true, + transformed: true, + type: 'diagram-links', + models: linkModels, + locked: false, + selected: false, + extras: {}, + }, + ], + }; + + return serializedGraph; + } + ); } diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts b/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts index 728015e..cac4a93 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts @@ -9,14 +9,65 @@ import { RootState } from '../../store'; export const FLOW_FEATURE_KEY = 'flow'; +export type PointModel = { + id: string; + type: string; + x: number; + y: number; +}; + +export type PortModel = { + id: string; + type: string; + x: number; + y: number; + name: string; + alignment: string; + parentNode: string; + links: string[]; + in: boolean; + label: string; +}; + +export type LinkModel = { + id: string; + type: string; + source: string; + sourcePort: string; + target: string; + targetPort: string; + points: PointModel[]; + labels: LabelModel[]; + width: number; + color: string; + curvyness: number; + selectedColor: string; + locked: boolean; + selected: boolean; + extras: Record; +}; + +export type LabelModel = { + id: string; + type: string; + offsetX: number; + offsetY: number; + label: string; +}; + // Define interfaces for the different entities export interface FlowNodeEntity { id: string; type: string; + x: number; + y: number; z?: string; // Flow ID for nodes that belong to a flow or subflow name?: string; wires?: string[][]; // For nodes, to represent connections [key: string]: unknown; // To allow for other properties dynamically + // React Diagrams + ports?: PortModel[]; + links?: Record; } export interface FlowEntity { From 6233e75bcce3bd0de8f39b8b0cf37b90eaf8eba2 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Thu, 18 Apr 2024 14:16:40 -0700 Subject: [PATCH 05/18] Finish implementing two-way state with flow-canvas and apply fixes --- .../src/app/components/flow-canvas/engine.ts | 21 +++++ .../components/flow-canvas/flow-canvas.tsx | 77 +++++++++++-------- .../src/app/components/flow-canvas/model.ts | 37 +++++++-- .../src/app/components/flow-canvas/node.tsx | 4 + .../src/app/redux/modules/flow/flow.logic.ts | 48 +++++++----- .../src/app/redux/modules/flow/flow.slice.ts | 2 + 6 files changed, 133 insertions(+), 56 deletions(-) diff --git a/packages/flow-client/src/app/components/flow-canvas/engine.ts b/packages/flow-client/src/app/components/flow-canvas/engine.ts index e22ca7a..200835e 100644 --- a/packages/flow-client/src/app/components/flow-canvas/engine.ts +++ b/packages/flow-client/src/app/components/flow-canvas/engine.ts @@ -19,6 +19,7 @@ import { DropTargetMonitor } from 'react-dnd'; import { CustomDiagramModel } from './model'; import { CustomNodeFactory } from './node'; +import { SerializedGraph } from '../../redux/modules/flow/flow.logic'; export class CustomEngine extends DiagramEngine { constructor(options?: CanvasEngineOptions) { @@ -153,6 +154,26 @@ export class CustomEngine extends DiagramEngine { return new Point(correctedX, correctedY); } + + public applySerializedGraph(serializedGraph: SerializedGraph) { + const model = this.getModel(); + + // don't overwrite some properties + serializedGraph.offsetX = model.getOffsetX() ?? 0; + serializedGraph.offsetY = model.getOffsetY() ?? 0; + serializedGraph.zoom = model.getZoomLevel() ?? 1; + serializedGraph.gridSize = model.getOptions().gridSize ?? 20; + // order our layers: links, nodes + serializedGraph.layers.sort((a, b) => { + if (a.type === 'diagram-links') return -1; + if (b.type === 'diagram-links') return 1; + return 0; + }); + + model.deserializeModel(serializedGraph, this); + + return this.repaintCanvas(true); + } } export const createEngine = (options = {}) => { diff --git a/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx b/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx index 0720ac2..f8834e0 100644 --- a/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx +++ b/packages/flow-client/src/app/components/flow-canvas/flow-canvas.tsx @@ -5,13 +5,23 @@ import { DefaultPortModel, PortModelAlignment, } from '@projectstorm/react-diagrams'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useDrop } from 'react-dnd'; import styled from 'styled-components'; import { useAppDispatch, useAppLogic, useAppSelector } from '../../redux/hooks'; import { SerializedGraph } from '../../redux/modules/flow/flow.logic'; -import { selectAllEntities } from '../../redux/modules/flow/flow.slice'; +import { + FlowNodeEntity, + selectAllEntities, + selectFlows, +} from '../../redux/modules/flow/flow.slice'; import { NodeEntity } from '../../redux/modules/node/node.slice'; import { ItemTypes } from '../node/draggable-item-types'; // Assuming ItemTypes is defined elsewhere import { createEngine } from './engine'; @@ -99,8 +109,11 @@ export const FlowCanvas: React.FC = ({ const dispatch = useAppDispatch(); const nodeLogic = useAppLogic().node; const flowLogic = useAppLogic().flow; + const existingFlows = useAppSelector(selectFlows); - const [model] = useState(new CustomDiagramModel()); + const [model] = useState( + new CustomDiagramModel({ id: existingFlows[0]?.id }) + ); const serializedGraph = useAppSelector(state => flowLogic.selectSerializedGraphByFlowId(state, model.getID()) @@ -120,23 +133,7 @@ export const FlowCanvas: React.FC = ({ engine.setModel(model); }, [initialDiagram.links, initialDiagram.nodes, model]); - useEffect(() => { - // we deserialize our graph in the same selector so that it doesn't trigger event handlers - if (serializedGraph) { - // don't overwrite some properties - serializedGraph.offsetX = model.getOffsetX() ?? 0; - serializedGraph.offsetY = model.getOffsetY() ?? 0; - serializedGraph.zoom = model.getZoomLevel() ?? 1; - serializedGraph.gridSize = model.getOptions().gridSize ?? 20; - // order our layers: links, nodes - serializedGraph.layers.sort((a, b) => { - if (a.type === 'diagram-links') return -1; - if (b.type === 'diagram-links') return 1; - return 0; - }); - // model.deserializeModel(serializedGraph, engine); - // engine.repaintCanvas(); - } + const registerModelChangeListener = useCallback(() => { // Event listener for any change in the model const handleModelChange = debounce(() => { // Serialize the current state of the diagram @@ -152,24 +149,44 @@ export const FlowCanvas: React.FC = ({ 'CustomDiagramModel:nodesUpdated': handleModelChange, 'CustomNodeModel:positionChanged': handleModelChange, 'DefaultLinkModel:targetPortChanged': handleModelChange, + 'DefaultLinkModel:sourcePortChanged': handleModelChange, 'DefaultLinkModel:pointsUpdated': handleModelChange, 'PointModel:positionChanged': handleModelChange, // Add more listeners as needed }); + }, [dispatch, flowLogic, model]); - return () => { - // Cleanup: use the ref to access the handle for deregistering listeners - if (listenerHandleRef.current) { - engine.deregisterListener(listenerHandleRef.current); - listenerHandleRef.current = null; // Reset the ref after cleanup + const deregisterModelChangeListener = useCallback(() => { + // Cleanup: use the ref to access the handle for deregistering listeners + if (listenerHandleRef.current) { + engine.deregisterListener(listenerHandleRef.current); + listenerHandleRef.current = null; + } + }, []); + + useEffect(() => { + let isCleanupCalled = false; + + (async () => { + // we deserialize our graph in the same effect so that it doesn't trigger event handlers + if (serializedGraph) { + await engine.applySerializedGraph(serializedGraph); } + + if (!isCleanupCalled) { + // apply listeners + registerModelChangeListener(); + } + })(); + + return () => { + // remove listeners + deregisterModelChangeListener(); + isCleanupCalled = true; }; }, [ - dispatch, - flowLogic, - initialDiagram.links, - initialDiagram.nodes, - model, + deregisterModelChangeListener, + registerModelChangeListener, serializedGraph, ]); diff --git a/packages/flow-client/src/app/components/flow-canvas/model.ts b/packages/flow-client/src/app/components/flow-canvas/model.ts index 566ec74..8d1da26 100644 --- a/packages/flow-client/src/app/components/flow-canvas/model.ts +++ b/packages/flow-client/src/app/components/flow-canvas/model.ts @@ -1,16 +1,16 @@ import { + BaseEvent, + BaseModel, DiagramModel, - NodeModel, + LayerModel, LinkModel, - BaseEvent, - PortModel, + NodeModel, PointModel, + PortModel, } from '@projectstorm/react-diagrams'; export class CustomDiagramModel extends DiagramModel { - // Custom method to add a node and register an event listener - override addNode(node: NodeModel): NodeModel { - const ret = super.addNode(node); + private attachNodeListeners(node: NodeModel) { // Register an event listener for the node node.registerListener({ eventDidFire: (e: BaseEvent) => { @@ -58,6 +58,31 @@ export class CustomDiagramModel extends DiagramModel { this.fireEvent(e, '_globalPassthrough'); }, }); + } + + // Custom method to add a node and register an event listener + override addNode(node: NodeModel): NodeModel { + const ret = super.addNode(node); + this.attachNodeListeners(node); + return ret; + } + + // Custom method to add a link and register an event listener + override addLink(link: LinkModel): LinkModel { + const ret = super.addLink(link); + this.attachLinkListeners(link); + return ret; + } + + override addLayer(layer: LayerModel): void { + const ret = super.addLayer(layer); + Object.values(layer.getModels()).forEach((model: BaseModel) => { + if (model instanceof LinkModel) { + this.attachLinkListeners(model); + } else if (model instanceof NodeModel) { + this.attachNodeListeners(model); + } + }); return ret; } } diff --git a/packages/flow-client/src/app/components/flow-canvas/node.tsx b/packages/flow-client/src/app/components/flow-canvas/node.tsx index ec26a8a..ece30e0 100644 --- a/packages/flow-client/src/app/components/flow-canvas/node.tsx +++ b/packages/flow-client/src/app/components/flow-canvas/node.tsx @@ -134,9 +134,13 @@ export const Node: React.FC = ({ node, engine }) => { // Convert the ports model to an array for rendering const ports = Object.values(node.getPorts()); + + const entity = node.entity ?? ({} as NodeEntity); + return ( + {/* Render ports */} {ports.map((port, index) => ( diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts index 1fb00d9..286c98a 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts @@ -113,34 +113,42 @@ export class FlowLogic { flowActions.removeEntities(nodesToRemove.map(it => it.id)) ); - // First, map all links by their sourcePort to easily find connections later - const linksBySourcePort = Object.values(linkModels) - .filter(it => it.targetPort in portModels) + // Map all links by their out port to organize connections from out -> in + const linksByOutPort = Object.values(linkModels) + .filter( + link => + link.sourcePort in portModels && + link.targetPort in portModels + ) .reduce>((acc, link) => { - (acc[link.sourcePort] = acc[link.sourcePort] || []).push( - portModels[link.targetPort].parentNode - ); + const outPortId = portModels[link.sourcePort].in + ? link.targetPort + : link.sourcePort; + const inPortId = portModels[link.sourcePort].in + ? link.sourcePort + : link.targetPort; + const inPortNode = portModels[inPortId].parentNode; + (acc[outPortId] = acc[outPortId] || []).push(inPortNode); return acc; }, {}); - // Transform nodes, incorporating links data into the wires property + // Transform nodes, incorporating links data into the wires property based on out ports const nodes = Object.values(nodeModels).map( (node): FlowNodeEntity => { - // For each port of the node + // For each out port of the node const wires: string[][] = []; const outLinks: Record = {}; node.ports - // only look at out ports - .filter(it => !it.in) - //find connected target ports and map them to target node IDs + .filter(port => !port.in) // only look at out ports .forEach(port => { - wires.push(linksBySourcePort[port.id] || []); + wires.push(linksByOutPort[port.id] || []); port.links.forEach(linkId => { outLinks[linkId] = linkModels[linkId]; }); }); return { + ...(node.extras.config as FlowNodeEntity), id: node.id, type: node.extras.entity.type, x: node.x, @@ -148,9 +156,10 @@ export class FlowLogic { z: graph.id, // Assuming all nodes belong to the same flow name: node.name, wires, - // Add other properties as needed ports: node.ports, links: outLinks, + selected: node.selected, + locked: node.locked, }; } ); @@ -226,17 +235,16 @@ export class FlowLogic { }); nodeModels[node.id] = { - id: node.id, - type: 'custom-node', - x: node.x, - y: node.y, - ports: node.ports ?? [], - name: node.name || '', - color: 'defaultColor', locked: false, selected: false, + ports: [], + name: '', + color: 'defaultColor', + ...node, + type: 'custom-node', extras: { entity: nodeEntities[node.type], + config: node, }, }; }); diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts b/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts index cac4a93..c6113a3 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts @@ -66,6 +66,8 @@ export interface FlowNodeEntity { wires?: string[][]; // For nodes, to represent connections [key: string]: unknown; // To allow for other properties dynamically // React Diagrams + selected?: boolean; + locked?: boolean; ports?: PortModel[]; links?: Record; } From f9f4447bf2e81541c42f606ac221600d8a66fad7 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Thu, 18 Apr 2024 14:19:57 -0700 Subject: [PATCH 06/18] Install and configure redux-persist for flow slice --- package.json | 1 + packages/flow-client/src/app/redux/logic.tsx | 15 +++++-- packages/flow-client/src/app/redux/store.ts | 41 ++++++++++++++++---- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 9a99b3d..2ce2d38 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-dom": "18.2.0", "react-is": "18.2.0", "react-redux": "^9.1.0", + "redux-persist": "^6.0.0", "styled-components": "5.3.6" }, "devDependencies": { diff --git a/packages/flow-client/src/app/redux/logic.tsx b/packages/flow-client/src/app/redux/logic.tsx index 641813d..023928d 100644 --- a/packages/flow-client/src/app/redux/logic.tsx +++ b/packages/flow-client/src/app/redux/logic.tsx @@ -1,9 +1,12 @@ -import React, { createContext, ReactNode } from 'react'; +import { createContext, ReactNode } from 'react'; import { Provider } from 'react-redux'; -import { AppStore } from './store'; +import persistStore from 'redux-persist/es/persistStore'; +import { PersistGate } from 'redux-persist/integration/react'; + import { FeatureLogic } from './modules/feature/feature.logic'; -import { NodeLogic } from './modules/node/node.logic'; import { FlowLogic } from './modules/flow/flow.logic'; +import { NodeLogic } from './modules/node/node.logic'; +import { AppStore } from './store'; export const createLogic = () => ({ feature: new FeatureLogic(), @@ -27,6 +30,10 @@ type ProviderProps = { export const AppProvider = ({ store, logic, children }: ProviderProps) => ( - {children} + + + {children} + + ); diff --git a/packages/flow-client/src/app/redux/store.ts b/packages/flow-client/src/app/redux/store.ts index 8618d9f..51de57f 100644 --- a/packages/flow-client/src/app/redux/store.ts +++ b/packages/flow-client/src/app/redux/store.ts @@ -1,5 +1,15 @@ import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; +import { + FLUSH, + PAUSE, + PERSIST, + persistReducer, + PURGE, + REGISTER, + REHYDRATE, +} from 'redux-persist'; +import storage from 'redux-persist/lib/storage'; import { featureApi } from './modules/api/feature.api'; import { nodeApi } from './modules/api/node.api'; // Import the nodeApi @@ -7,9 +17,12 @@ import { FEATURE_FEATURE_KEY, featureReducer, } from './modules/feature/feature.slice'; +import { + FLOW_FEATURE_KEY, + flowReducer, + FlowState, +} from './modules/flow/flow.slice'; import { NODE_FEATURE_KEY, nodeReducer } from './modules/node/node.slice'; -import { FLOW_FEATURE_KEY, flowReducer } from './modules/flow/flow.slice'; -// Add more imports for other slices as needed export const createStore = () => { const store = configureStore({ @@ -19,14 +32,28 @@ export const createStore = () => { [NODE_FEATURE_KEY]: nodeReducer, [nodeApi.reducerPath]: nodeApi.reducer, // Add the nodeApi reducer // Add more reducers here as needed - [FLOW_FEATURE_KEY]: flowReducer, + [FLOW_FEATURE_KEY]: persistReducer( + { + key: FLOW_FEATURE_KEY, + storage: storage, + }, + flowReducer + ), }, // Additional middleware can be passed to this array middleware: getDefaultMiddleware => - getDefaultMiddleware().concat( - featureApi.middleware, - nodeApi.middleware - ), + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [ + FLUSH, + REHYDRATE, + PAUSE, + PERSIST, + PURGE, + REGISTER, + ], + }, + }).concat(featureApi.middleware, nodeApi.middleware), devTools: process.env.NODE_ENV !== 'production', }); From d66792fa5799887739a7dc4a2c3e15d8537e2b25 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Thu, 18 Apr 2024 14:39:32 -0700 Subject: [PATCH 07/18] Fix current errors in PR --- packages/flow-client/src/app/components/flow-canvas/node.tsx | 1 - packages/flow-client/src/app/redux/logic.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flow-client/src/app/components/flow-canvas/node.tsx b/packages/flow-client/src/app/components/flow-canvas/node.tsx index ece30e0..e3c8b8c 100644 --- a/packages/flow-client/src/app/components/flow-canvas/node.tsx +++ b/packages/flow-client/src/app/components/flow-canvas/node.tsx @@ -140,7 +140,6 @@ export const Node: React.FC = ({ node, engine }) => { return ( - {/* Render ports */} {ports.map((port, index) => ( diff --git a/packages/flow-client/src/app/redux/logic.tsx b/packages/flow-client/src/app/redux/logic.tsx index 023928d..ea63075 100644 --- a/packages/flow-client/src/app/redux/logic.tsx +++ b/packages/flow-client/src/app/redux/logic.tsx @@ -1,6 +1,6 @@ import { createContext, ReactNode } from 'react'; import { Provider } from 'react-redux'; -import persistStore from 'redux-persist/es/persistStore'; +import persistStore from 'redux-persist'; import { PersistGate } from 'redux-persist/integration/react'; import { FeatureLogic } from './modules/feature/feature.logic'; From d6df46744a6944bbcbdeba76c8e73abc8ddb6b04 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Thu, 18 Apr 2024 14:48:16 -0700 Subject: [PATCH 08/18] Correct redux-persist import --- packages/flow-client/src/app/redux/logic.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flow-client/src/app/redux/logic.tsx b/packages/flow-client/src/app/redux/logic.tsx index ea63075..485e72a 100644 --- a/packages/flow-client/src/app/redux/logic.tsx +++ b/packages/flow-client/src/app/redux/logic.tsx @@ -1,6 +1,6 @@ import { createContext, ReactNode } from 'react'; import { Provider } from 'react-redux'; -import persistStore from 'redux-persist'; +import { persistStore } from 'redux-persist'; import { PersistGate } from 'redux-persist/integration/react'; import { FeatureLogic } from './modules/feature/feature.logic'; From 2eef9060bc8b5eecad96ef5ff6889f6eeea7aed8 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Thu, 18 Apr 2024 15:10:42 -0700 Subject: [PATCH 09/18] Mock and copy various modules from the Node-RED client for node editing --- package-lock.json | 87 +- package.json | 5 + .../flow-client/src/app/red/execute-script.ts | 195 + .../flow-client/src/app/red/mock-editor.ts | 216 + .../flow-client/src/app/red/mock-jquery.ts | 1591 ++ .../flow-client/src/app/red/mock-popover.ts | 41 + packages/flow-client/src/app/red/mock-red.ts | 246 + .../src/app/red/red-code-editor.js | 130 + .../src/app/red/red-editable-list.js | 457 + packages/flow-client/src/app/red/red-i18n.js | 171 + .../src/app/red/red-locales-en-us-editor.ts | 1325 ++ .../src/app/red/red-locales-en-us-nodes.ts | 1168 ++ .../flow-client/src/app/red/red-monaco.js | 2214 +++ .../flow-client/src/app/red/red-style.css | 14036 ++++++++++++++++ packages/flow-client/src/app/red/red-tabs.js | 1309 ++ .../src/app/red/red-typed-input.js | 1602 ++ packages/flow-client/src/app/red/red-utils.js | 2129 +++ 17 files changed, 26918 insertions(+), 4 deletions(-) create mode 100644 packages/flow-client/src/app/red/execute-script.ts create mode 100644 packages/flow-client/src/app/red/mock-editor.ts create mode 100644 packages/flow-client/src/app/red/mock-jquery.ts create mode 100644 packages/flow-client/src/app/red/mock-popover.ts create mode 100644 packages/flow-client/src/app/red/mock-red.ts create mode 100644 packages/flow-client/src/app/red/red-code-editor.js create mode 100644 packages/flow-client/src/app/red/red-editable-list.js create mode 100644 packages/flow-client/src/app/red/red-i18n.js create mode 100644 packages/flow-client/src/app/red/red-locales-en-us-editor.ts create mode 100644 packages/flow-client/src/app/red/red-locales-en-us-nodes.ts create mode 100644 packages/flow-client/src/app/red/red-monaco.js create mode 100644 packages/flow-client/src/app/red/red-style.css create mode 100644 packages/flow-client/src/app/red/red-tabs.js create mode 100644 packages/flow-client/src/app/red/red-typed-input.js create mode 100644 packages/flow-client/src/app/red/red-utils.js diff --git a/package-lock.json b/package-lock.json index d412a2d..e52244c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,24 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.2", "@projectstorm/react-canvas-core": "^7.0.3", "@projectstorm/react-diagrams": "^7.0.4", "@reduxjs/toolkit": "^2.2.3", "dompurify": "^3.1.0", + "i18next": "^23.11.2", + "jquery-i18next": "^1.2.1", + "jsonata": "^2.0.4", "marked": "^12.0.1", + "monaco-editor": "=0.47.0", "react": "18.2.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-is": "18.2.0", "react-redux": "^9.1.0", + "react-shadow": "^20.4.0", + "redux-persist": "^6.0.0", "styled-components": "5.3.6" }, "devDependencies": { @@ -2766,6 +2773,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz", + "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -8520,6 +8536,33 @@ "node": ">=10.17.0" } }, + "node_modules/humps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", + "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==" + }, + "node_modules/i18next": { + "version": "23.11.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.2.tgz", + "integrity": "sha512-qMBm7+qT8jdpmmDw/kQD16VpmkL9BdL+XNAK5MNbNFaf1iQQq35ZbPrSlqmnNPOSUY4m342+c0t0evinF5l7sA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -9155,6 +9198,11 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jquery-i18next": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/jquery-i18next/-/jquery-i18next-1.2.1.tgz", + "integrity": "sha512-UNcw3rgxoKjGEg4w23FEn2h3OlPJU7rPzsgDuXDBZktIzeiVbJohs9Cv9hj8oP8KNfBRKOoErL/OVxg2FaAR4g==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9259,6 +9307,14 @@ "node": ">=6" } }, + "node_modules/jsonata": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-2.0.4.tgz", + "integrity": "sha512-vfavX4/G/yrYxE+UrmT/oUJ3ph7KqUrb0R7b0LVRcntQwxw+Z5kA1pNUIQzX5hF04Oe1eKxyoIPsmXtc2LgJTQ==", + "engines": { + "node": ">= 8" + } + }, "node_modules/jsonc-eslint-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz", @@ -9663,6 +9719,11 @@ "ufo": "^1.3.2" } }, + "node_modules/monaco-editor": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.47.0.tgz", + "integrity": "sha512-VabVvHvQ9QmMwXu4du008ZDuyLnHs9j7ThVFsiJoXSOQk18+LF89N4ADzPbFenm0W4V2bGHnFBztIRQTgBfxzw==" + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -9854,7 +9915,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -10437,7 +10497,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -10447,8 +10506,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-from-env": { "version": "1.1.0", @@ -10640,6 +10698,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-shadow": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/react-shadow/-/react-shadow-20.4.0.tgz", + "integrity": "sha512-sirvAmFja7Ss6MoyQbKWxaQ5IDTAW3Za3Tvegylfr5jXnwKZObHRIyiatefeNlskoGKfuPaZ8DNT052T0SUGcg==", + "dependencies": { + "humps": "^2.0.1" + }, + "peerDependencies": { + "prop-types": "^15.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -10675,6 +10746,14 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "peerDependencies": { + "redux": ">4.0.0" + } + }, "node_modules/redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", diff --git a/package.json b/package.json index 2ce2d38..116386e 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,16 @@ "type": "module", "private": true, "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.2", "@projectstorm/react-canvas-core": "^7.0.3", "@projectstorm/react-diagrams": "^7.0.4", "@reduxjs/toolkit": "^2.2.3", "dompurify": "^3.1.0", + "i18next": "^23.11.2", + "jquery-i18next": "^1.2.1", + "jsonata": "^2.0.4", "marked": "^12.0.1", + "monaco-editor": "=0.47.0", "react": "18.2.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/packages/flow-client/src/app/red/execute-script.ts b/packages/flow-client/src/app/red/execute-script.ts new file mode 100644 index 0000000..580159c --- /dev/null +++ b/packages/flow-client/src/app/red/execute-script.ts @@ -0,0 +1,195 @@ +import { NodeEntity } from '../redux/modules/node/node.slice'; +import { Context } from './mock-jquery'; +import { createMockRed } from './mock-red'; + +const executeDefinitionScript = ( + definitionScript: string, + RED: ReturnType +) => { + // eslint-disable-next-line no-new-func + const scriptFunction = new Function('RED', '$', definitionScript); + + try { + // Call the script function with the RED object + scriptFunction(RED, RED.$); + } catch (error) { + console.error('Error executing script:', error); + } +}; + +export const createNodeInstance = (nodeConfig: Record) => { + const RED = createMockRed(); + const nodeInstance = new Proxy( + { + ...nodeConfig, + _(messagePath: string) { + return RED._(`node-red:${messagePath}`); + }, + }, + // proxy handler + { + get: function (target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } else { + console.error( + `Attempted to access Node instance property: \`${String( + prop + )}\` but it was not emulated.` + ); + return undefined; + } + }, + } + ); + + return nodeInstance; +}; + +// Utility function to deserialize a function from its serialized string representation +export const deserializeFunction = unknown>( + serializedFunction: string, + nodeConfig: Record, + context = createNodeInstance(nodeConfig) +): T => { + const nodeInstance = context; + try { + console.debug('Deserializing function: '); + console.debug(serializedFunction); + // eslint-disable-next-line no-new-func + return new Function( + 'nodeInstance', + `return (${serializedFunction}).bind(nodeInstance);` + )(nodeInstance); + } catch (error) { + console.error('Error deserializing function: '); + console.info(serializedFunction); + throw error; + } +}; + +export const executeRegisterType = (definitionScript: string) => { + let registeredType = null; + + const RED = createMockRed(); + RED.nodes.registerType = ( + type: string, + definition: Partial + ) => { + // recursively iterate through definition and serialize any functions + const serializeFunctions = (obj: Record) => { + Object.keys(obj).forEach(key => { + if (typeof obj[key] === 'function') { + obj[key] = { + type: 'serialized-function', + value: ( + obj[key] as (...args: unknown[]) => unknown + ).toString(), + }; + } else if (typeof obj[key] === 'object' && obj[key] !== null) { + serializeFunctions(obj[key] as Record); + } + }); + }; + serializeFunctions(definition); + registeredType = { + type, + definition: definition, + }; + }; + + executeDefinitionScript(definitionScript, RED); + + return registeredType as { + type: string; + definition: Partial; + } | null; +}; + +export const extractNodePropertyFn = unknown>( + definitionScript: string, + propertyPath: string, + rootContext: Context = window.document, + nodeConfig: Record = {}, + context = createNodeInstance(nodeConfig) +) => { + let propertyFn = null; + + const RED = createMockRed(rootContext); + const nodeInstance = context; + + RED.nodes.registerType = ( + type: string, + definition: Partial + ) => { + const getPropertyByPath = ( + obj: Record, + path: string + ) => { + return path + .split('.') + .reduce( + (acc, part) => + acc && (acc[part] as Record), + obj + ); + }; + + const propertyFunction = getPropertyByPath(definition, propertyPath); + if (typeof propertyFunction === 'function') { + propertyFn = ( + propertyFunction as (...args: unknown[]) => unknown + ).bind(nodeInstance) as unknown as T; + } + }; + + executeDefinitionScript(definitionScript, RED); + + return propertyFn as T | null; +}; + +export const finalizeNodeExecution = ( + dialogForm: HTMLElement, + rootContext: Context = window.document +) => { + // apply monaco styles to shadow dom + const linkElement = document.createElement('link'); + linkElement.setAttribute('rel', 'stylesheet'); + linkElement.setAttribute('type', 'text/css'); + linkElement.setAttribute('data-name', 'vs/editor/editor.main'); + linkElement.setAttribute( + 'href', + 'https://cdn.jsdelivr.net/npm/monaco-editor@0.47.0/dev/vs/editor/editor.main.css' + ); + + rootContext.appendChild(linkElement); + + // apply node-red namespace to i18n keys + Array.from(dialogForm.querySelectorAll('[data-i18n]')).forEach( + element => { + const currentKeys = element.dataset.i18n as string; + const newKeys = currentKeys + .split(';') + .map(key => { + if (key.includes(':')) { + return key; + } + + let prefix = ''; + if (key.startsWith('[')) { + const parts = key.split(']'); + prefix = parts[0] + ']'; + key = parts[1]; + } + + return `${prefix}node-red:${key}`; + }) + .join(';'); + element.dataset.i18n = newKeys; + } + ); + + // call i18n plugin on newly created content + const RED = createMockRed(rootContext); + (RED.$(dialogForm) as unknown as { i18n: () => void }).i18n(); +}; diff --git a/packages/flow-client/src/app/red/mock-editor.ts b/packages/flow-client/src/app/red/mock-editor.ts new file mode 100644 index 0000000..d7b02a5 --- /dev/null +++ b/packages/flow-client/src/app/red/mock-editor.ts @@ -0,0 +1,216 @@ +import type { RedType } from './mock-red'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { createRedCodeEditor } from './red-code-editor'; + +type TypeEditorDefinition = { + show: (options: Record) => void; +}; + +type CustomEditTypes = Record; + +export type RedEditorType = { + init: () => void; + generateViewStateId: () => void; + edit: () => void; + editConfig: () => void; + editFlow: () => void; + editSubflow: () => void; + editGroup: () => void; + editJavaScript: (options: Record) => void; + editExpression: (options: Record) => void; + editJSON: (options: Record) => void; + editMarkdown: (options: Record) => void; + editText: (options: Record) => void; + editBuffer: (options: Record) => void; + buildEditForm: () => void; + validateNode: () => void; + updateNodeProperties: () => void; + showIconPicker: (...args: unknown[]) => void; + showTypeEditor: (type: string, options: Record) => void; + registerTypeEditor: ( + type: string, + definition: TypeEditorDefinition + ) => void; + createEditor: ( + options: Record + ) => ReturnType; + customEditTypes: CustomEditTypes; + registerEditPane: ( + type: string, + definition: object, + filter?: (...args: unknown[]) => void + ) => void; + codeEditor: ReturnType; +}; + +export const createMockEditor = (RED: RedType, $: unknown) => { + type FilteredEditPanes = Record void>; + type EditPanes = Record; + + const customEditTypes: CustomEditTypes = {}; + const filteredEditPanes: FilteredEditPanes = {}; + const editPanes: EditPanes = {}; + const editStack: { + type: string; + name?: string; + id?: string; + }[] = []; + + const getEditStackTitle = (): string => { + let label = ''; + for (let i = editStack.length - 1; i < editStack.length; i++) { + const node = editStack[i]; + label = node.type; + if (node.type === 'group') { + label = RED._('group.editGroup', { + name: RED.utils.sanitize(node.name ?? node.id ?? ''), + }); + } else if (node.type === '_expression') { + label = RED._('expressionEditor.title'); + } else if (node.type === '_js') { + label = RED._('jsEditor.title'); + } else if (node.type === '_text') { + label = RED._('textEditor.title'); + } else if (node.type === '_json') { + label = RED._('jsonEditor.title'); + } else if (node.type === '_markdown') { + label = RED._('markdownEditor.title'); + } else if (node.type === '_buffer') { + label = RED._('bufferEditor.title'); + } else if (node.type === 'subflow') { + label = RED._('subflow.editSubflow', { + name: RED.utils.sanitize(node.name ?? ''), + }); + } else if (node.type.indexOf('subflow:') === 0) { + label = 'Sublow'; + } + } + return label; + }; + + return new Proxy( + { + init: function (): void { + RED.editor.codeEditor.init(); + }, + generateViewStateId: () => undefined, + edit: () => undefined, + editConfig: () => undefined, + editFlow: () => undefined, + editSubflow: () => undefined, + editGroup: () => undefined, + editJavaScript: function (options: Record): void { + this.showTypeEditor('_js', options); + }, + editExpression: function (options: Record): void { + this.showTypeEditor('_expression', options); + }, + editJSON: function (options: Record): void { + this.showTypeEditor('_json', options); + }, + editMarkdown: function (options: Record): void { + this.showTypeEditor('_markdown', options); + }, + editText: function (options: Record): void { + if (options.mode === 'markdown') { + this.showTypeEditor('_markdown', options); + } else { + this.showTypeEditor('_text', options); + } + }, + editBuffer: function (options: Record): void { + this.showTypeEditor('_buffer', options); + }, + buildEditForm: () => undefined, + validateNode: () => undefined, + updateNodeProperties: () => undefined, + + showIconPicker: function (..._args: unknown[]): void { + // RED.editor.iconPicker.show.apply(null, args); + }, + + /** + * Show a type editor. + * @param type - the type to display + * @param options - options for the editor + */ + showTypeEditor: ( + type: string, + options: Record + ): void => { + if ( + Object.prototype.hasOwnProperty.call(customEditTypes, type) + ) { + if (editStack.length > 0) { + options.parent = editStack[editStack.length - 1].id; + } + editStack.push({ type: type }); + options.title = options.title || getEditStackTitle(); + options.onclose = (): void => { + editStack.pop(); + }; + customEditTypes[type].show(options); + } else { + console.log('Unknown type editor:', type); + } + }, + + /** + * Register a type editor. + * @param {string} type - the type name + * @param {object} definition - the editor definition + * @function + * @memberof RED.editor + */ + registerTypeEditor: function ( + type: string, + definition: TypeEditorDefinition + ): void { + customEditTypes[type] = definition; + }, + + /** + * Create a editor ui component + * @param {object} options - the editor options + * @returns The code editor + * @memberof RED.editor + */ + createEditor(options: Record) { + return this.codeEditor.create(options); + }, + + get customEditTypes() { + return customEditTypes; + }, + + registerEditPane: function ( + type: string, + definition: object, + filter?: (...args: unknown[]) => void + ): void { + if (filter) { + filteredEditPanes[type] = filter; + } + editPanes[type] = definition; + }, + + codeEditor: createRedCodeEditor(RED, $), + }, + // proxy handler + { + get: function (target, prop: string | symbol) { + if (prop in target) { + return target[prop as keyof typeof target]; + } else { + console.error( + `Attempted to access RED editor property: \`${String( + prop + )}\` but it was not emulated.` + ); + return undefined; + } + }, + } + ); +}; diff --git a/packages/flow-client/src/app/red/mock-jquery.ts b/packages/flow-client/src/app/red/mock-jquery.ts new file mode 100644 index 0000000..457fe6d --- /dev/null +++ b/packages/flow-client/src/app/red/mock-jquery.ts @@ -0,0 +1,1591 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { applyTypedInput } from './red-typed-input'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { applyEditableList } from './red-editable-list'; + +export type Context = Element | Document | ShadowRoot; + +export const createMockJquery = (RED: unknown) => { + class jQuery { + private elements: Element[]; + private previousContext?: jQuery; + + constructor( + selector: string, + context?: Context | Record + ); + constructor(selector: Element); + constructor(selector: Element[]); + constructor(selector: jQuery); + constructor( + selector: string | Element | Element[] | jQuery, + context?: Context | Record + ); + constructor( + selector: string | Element | Element[] | jQuery, + context?: Context | Record + ) { + if (selector instanceof jQuery) { + this.elements = selector.elements; + } else if (typeof selector === 'string') { + // Check if selector is HTML string + if ( + selector.trim().startsWith('<') && + selector.trim().endsWith('>') + ) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = selector; + this.elements = Array.from(tempDiv.children); + if ( + context && + !( + context instanceof Element || + context instanceof Document || + context instanceof ShadowRoot + ) + ) { + this.elements.forEach(element => { + Object.entries(context).forEach(([key, value]) => { + element.setAttribute(key, value as string); + }); + }); + } else { + const ownerDocument = + context instanceof Document ? context : document; + this.elements.forEach(element => { + ownerDocument.adoptNode(element); + }); + } + } else if (!context) { + this.elements = Array.from( + document.querySelectorAll(selector) + ); + } else if ( + context instanceof Element || + context instanceof Document || + context instanceof ShadowRoot + ) { + this.elements = Array.from( + context.querySelectorAll(selector) + ); + } else if ( + context && + typeof context === 'object' && + !( + context instanceof Element || + context instanceof Document || + context instanceof ShadowRoot + ) + ) { + console.error(`Could not handle given object context`); + this.elements = []; // No elements in arbitrary object context + } else if (Array.isArray(context)) { + this.elements = context.filter(el => el.matches(selector)); + } else { + console.error(`Could not handle given context`); + this.elements = []; + } + } else if (selector instanceof Element) { + this.elements = [selector]; + } else if (Array.isArray(selector)) { + this.elements = selector; + } else { + this.elements = []; + } + } + + private newContext(selector: string | Element | Element[]): jQuery { + const newContext = jQueryFn(selector); + newContext.previousContext = this; + return newContext; + } + + addClass(className: string): jQuery { + this.elements + .filter(el => el instanceof HTMLElement) + .forEach(element => { + (element as HTMLElement).classList.add(className); + }); + return this; + } + + animate(): jQuery { + // Placeholder: Actual animation logic would be complex to implement + console.warn('animate() not implemented in mock-jQuery.'); + return this; + } + + append(content: string | Element | jQuery): jQuery { + this.elements.forEach((el, index) => { + if (typeof content === 'string') { + el.insertAdjacentHTML('beforeend', content); + } else if (content instanceof Element) { + if (index === this.elements.length - 1) { + el.appendChild(content); + } else { + el.appendChild(content.cloneNode(true)); + } + } else if (content instanceof jQuery) { + content.elements.forEach((contentEl, contentIndex) => { + if (contentEl instanceof Node) { + if ( + index === this.elements.length - 1 && + contentIndex === content.elements.length - 1 + ) { + el.appendChild(contentEl); + } else { + el.appendChild(contentEl.cloneNode(true)); + } + } + }); + } + }); + return this; + } + + appendTo(target: string | Element | jQuery): jQuery { + if (typeof target === 'string') { + const targets = document.querySelectorAll(target); + targets.forEach((t, targetIndex) => { + this.elements.forEach((el, elIndex) => { + if (el instanceof Node) { + if ( + targetIndex === targets.length - 1 && + elIndex === this.elements.length - 1 + ) { + t.appendChild(el); + } else { + const clonedNode = el.cloneNode(true); + t.appendChild(clonedNode); + this.elements.push(clonedNode as Element); + } + } + }); + }); + } else if (target instanceof Element) { + this.elements.forEach((el, index) => { + if (el instanceof Node) { + if (index === this.elements.length - 1) { + target.appendChild(el); + } else { + const clonedNode = el.cloneNode(true); + target.appendChild(clonedNode); + this.elements.push(clonedNode as Element); + } + } + }); + } else if (target instanceof jQuery) { + target.elements.forEach((t, targetIndex) => { + this.elements.forEach((el, elIndex) => { + if (el instanceof Node) { + if ( + targetIndex === target.elements.length - 1 && + elIndex === this.elements.length - 1 + ) { + t.appendChild(el); + } else { + const clonedNode = el.cloneNode(true); + t.appendChild(clonedNode); + this.elements.push(clonedNode as Element); + } + } + }); + }); + } + return this; + } + + attr( + attributeName: string, + value?: string + ): jQuery | string | undefined { + if (value === undefined) { + // Acting as a getter + if (this.elements.length > 0) { + return ( + this.elements[0].getAttribute(attributeName) ?? + undefined + ); + } + return undefined; + } else { + // Acting as a setter + this.elements.forEach(el => { + el.setAttribute(attributeName, value); + }); + return this; + } + } + + blur(): jQuery { + if (this.elements[0] instanceof HTMLElement) { + this.elements[0].blur(); + } + return this; + } + + change(handler?: (this: Element, ev: Event) => unknown): jQuery { + if (handler) { + return this.on('change', handler); + } else { + return this.trigger('change'); + } + } + + children(selector?: string): jQuery { + const filteredChildren = this.elements.flatMap(el => + Array.from( + selector ? el.querySelectorAll(selector) : el.children + ) + ); + return this.newContext(filteredChildren); + } + + click(handler?: (this: Element, ev: MouseEvent) => unknown): jQuery { + if (handler) { + return this.on( + 'click', + handler as (this: Element, ev: Event) => unknown + ); + } else { + return this.trigger('click'); + } + } + + clone(): jQuery { + const clonedElements = this.elements.map(el => + el.cloneNode(true) + ) as Element[]; + return this.newContext(clonedElements); + } + + closest(selector: string): jQuery { + const closestElements = this.elements + .map(el => el.closest(selector)) + .filter(el => el !== null) as Element[]; + return this.newContext(closestElements); + } + + css( + propertyName: string | string[] | Record, + value?: string + ): jQuery | string | Record | undefined { + if (typeof propertyName === 'string') { + if (value === undefined) { + // Acting as a getter for a single property + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + return getComputedStyle( + this.elements[0] + ).getPropertyValue(propertyName); + } + return undefined; + } else { + // Acting as a setter for a single property + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + el.style.setProperty(propertyName, value); + } + }); + return this; + } + } else if (Array.isArray(propertyName)) { + // Acting as a getter for multiple properties + const styles: Record = {}; + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + const computedStyle = getComputedStyle(this.elements[0]); + propertyName.forEach(prop => { + styles[prop] = computedStyle.getPropertyValue(prop); + }); + } + return styles; + } else { + // Acting as a setter for multiple properties + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + Object.entries(propertyName).forEach( + ([prop, value]) => { + el.style.setProperty(prop, value); + } + ); + } + }); + return this; + } + } + + data(key: string, value?: T): T | jQuery { + if (value === undefined) { + return jQueryFn.data(this.elements[0], key); + } else { + // Acting as a setter + this.elements.forEach(el => { + jQueryFn.data(el, key, value); + }); + return this; + } + } + + dblclick(handler?: (this: Element, ev: MouseEvent) => unknown): jQuery { + if (handler) { + return this.on( + 'dblclick', + handler as (this: Element, ev: Event) => unknown + ); + } else { + return this.trigger('dblclick'); + } + } + + delay(): jQuery { + // No-op in this mock context + return this; + } + + detach(): jQuery { + this.elements.forEach(el => { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + }); + return this; + } + + each( + callback: (this: Element, index: number, element: Element) => void + ): jQuery { + this.elements.forEach((element, index) => { + callback.call(element, index, element); + }); + return this; + } + + empty(): jQuery { + this.elements.forEach(el => { + while (el.firstChild) { + el.removeChild(el.firstChild); + } + }); + return this; + } + + end(): jQuery { + return this.previousContext ?? this; + } + + fadeIn(): jQuery { + // Similar implementation to slideDown + return this.slideDown(); + } + + fadeOut(): jQuery { + // Similar implementation to slideUp + return this.slideUp(); + } + + fadeToggle(): jQuery { + // Similar implementation to slideToggle + return this.slideToggle(); + } + + find(selector: string): jQuery { + const newElements = this.elements.flatMap(el => + Array.from(el.querySelectorAll(selector)) + ); + return this.newContext(newElements); + } + + filter(selector: string): jQuery { + const filteredElements = this.elements.filter(el => + el.matches(selector) + ); + return this.newContext(filteredElements); + } + + first(): jQuery { + const firstElement = this.elements[0] ? [this.elements[0]] : []; + return this.newContext(firstElement); + } + + focus(): jQuery { + if (this.elements[0] instanceof HTMLElement) { + this.elements[0].focus(); + } + return this; + } + + focusin(handler?: (this: Element, ev: FocusEvent) => unknown): jQuery { + if (handler) { + return this.on( + 'focusin', + handler as (this: Element, ev: Event) => unknown + ); + } else { + // focusin might not be directly triggerable as it's not a standard DOM event in all browsers + console.warn('focusin trigger not supported in mock-jQuery.'); + } + return this; + } + + focusout(handler?: (this: Element, ev: FocusEvent) => unknown): jQuery { + if (handler) { + return this.on( + 'focusout', + handler as (this: Element, ev: Event) => unknown + ); + } else { + // focusout might not be directly triggerable as it's not a standard DOM event in all browsers + console.warn('focusout trigger not supported in mock-jQuery.'); + } + return this; + } + + get(index?: number): jQuery | Element | undefined { + if (index !== undefined) { + return this.elements[index]; + } + return this; + } + + hasClass(className: string): boolean { + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + return this.elements[0].classList.contains(className); + } + return false; + } + + height(): number | undefined { + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + return this.elements[0].clientHeight; + } + return undefined; + } + + hide(): jQuery { + this.elements + .filter(el => el instanceof HTMLElement) + .forEach(element => { + (element as HTMLElement).style.display = 'none'; + }); + return this; + } + + hover( + handlerIn: (this: Element, ev: MouseEvent) => unknown, + handlerOut?: (this: Element, ev: MouseEvent) => unknown + ): jQuery { + this.elements.forEach(element => { + element.addEventListener( + 'mouseenter', + handlerIn as EventListener + ); + if (handlerOut) { + element.addEventListener( + 'mouseleave', + handlerOut as EventListener + ); + } + }); + return this; + } + + html(htmlString?: string): jQuery | string { + if (htmlString === undefined) { + // Acting as a getter + return this.elements[0] ? this.elements[0].innerHTML : ''; + } else { + // Acting as a setter + this.elements.forEach(el => { + el.innerHTML = htmlString; + }); + return this; + } + } + + index(selector?: string): number { + if (!selector) { + return this.elements[0] && this.elements[0].parentNode + ? Array.from(this.elements[0].parentNode.children).indexOf( + this.elements[0] + ) + : -1; + } else { + const elements = document.querySelectorAll(selector); + return Array.from(elements).indexOf(this.elements[0]); + } + } + + insertAfter(target: string | Element | jQuery): jQuery { + if (typeof target === 'string') { + document.querySelectorAll(target).forEach(t => { + this.elements.forEach(el => { + t.parentNode?.insertBefore(el, t.nextSibling); + }); + }); + } else if (target instanceof Element) { + this.elements.forEach(el => { + target.parentNode?.insertBefore(el, target.nextSibling); + }); + } else if (target instanceof jQuery) { + target.elements.forEach(t => { + this.elements.forEach(el => { + t.parentNode?.insertBefore(el, t.nextSibling); + }); + }); + } + return this; + } + + is(selector: string): boolean { + return this.elements.some(el => el.matches(selector)); + } + + keydown( + handler?: (this: Element, ev: KeyboardEvent) => unknown + ): jQuery { + if (handler) { + return this.on('keydown', (ev: Event) => + handler.call(ev.target as Element, ev as KeyboardEvent) + ); + } else { + return this.trigger('keydown'); + } + } + + keypress( + handler?: (this: Element, ev: KeyboardEvent) => unknown + ): jQuery { + if (handler) { + return this.on('keypress', (ev: Event) => + handler.call(ev.target as Element, ev as KeyboardEvent) + ); + } else { + return this.trigger('keypress'); + } + } + + keyup(handler?: (this: Element, ev: KeyboardEvent) => unknown): jQuery { + if (handler) { + return this.on('keyup', (ev: Event) => + handler.call(ev.target as Element, ev as KeyboardEvent) + ); + } else { + return this.trigger('keyup'); + } + } + + last(): jQuery { + const lastElement = + this.elements.length > 0 + ? [this.elements[this.elements.length - 1]] + : []; + return this.newContext(lastElement); + } + + get length(): number { + return this.elements.length; + } + + off( + eventType: string, + handler: (this: Element, ev: Event) => unknown + ): jQuery { + this.elements.forEach(element => { + element.removeEventListener(eventType, handler); + }); + return this; + } + + offset(): { top: number; left: number } | undefined { + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + const rect = this.elements[0].getBoundingClientRect(); + return { + top: rect.top + window.scrollY, + left: rect.left + window.scrollX, + }; + } + return undefined; + } + + on( + eventType: string, + handler: (this: Element, ev: Event) => unknown + ): jQuery { + this.elements.forEach(element => { + element.addEventListener(eventType, function (ev) { + handler.call(element, ev); + }); + }); + return this; + } + + one( + eventType: string, + handler: (this: Element, ev: Event) => unknown + ): jQuery { + this.elements.forEach(element => { + const onceHandler = (ev: Event) => { + handler.call(element, ev); + element.removeEventListener(eventType, onceHandler); + }; + element.addEventListener(eventType, onceHandler); + }); + return this; + } + + outerHeight(includeMargin?: boolean): number | undefined { + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + let height = this.elements[0].offsetHeight; + if (includeMargin) { + const style = getComputedStyle(this.elements[0]); + height += + parseInt(style.marginTop) + + parseInt(style.marginBottom); + } + return height; + } + return undefined; + } + + outerWidth(includeMargin?: boolean): number | undefined { + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + let width = this.elements[0].offsetWidth; + if (includeMargin) { + const style = getComputedStyle(this.elements[0]); + width += + parseInt(style.marginLeft) + + parseInt(style.marginRight); + } + return width; + } + return undefined; + } + + map(callback: (element: Element, index: number) => Element): jQuery { + const mappedElements = this.elements + .map((el, index) => callback(el, index)) + .filter(el => el != null); + return this.newContext(mappedElements); + } + + mousedown( + handler?: (this: Element, ev: MouseEvent) => unknown + ): jQuery { + if (handler) { + return this.on('mousedown', (ev: Event) => + handler.call(ev.target as Element, ev as MouseEvent) + ); + } else { + return this.trigger('mousedown'); + } + } + + mouseenter( + handler?: (this: Element, ev: MouseEvent) => unknown + ): jQuery { + if (handler) { + return this.on('mouseenter', (ev: Event) => + handler.call(ev.target as Element, ev as MouseEvent) + ); + } else { + return this.trigger('mouseenter'); + } + } + + mouseleave( + handler?: (this: Element, ev: MouseEvent) => unknown + ): jQuery { + if (handler) { + return this.on('mouseleave', (ev: Event) => + handler.call(ev.target as Element, ev as MouseEvent) + ); + } else { + return this.trigger('mouseleave'); + } + } + + mousemove( + handler?: (this: Element, ev: MouseEvent) => unknown + ): jQuery { + if (handler) { + return this.on('mousemove', (ev: Event) => + handler.call(ev.target as Element, ev as MouseEvent) + ); + } else { + return this.trigger('mousemove'); + } + } + + mouseout(handler?: (this: Element, ev: MouseEvent) => unknown): jQuery { + if (handler) { + return this.on('mouseout', (ev: Event) => + handler.call(ev.target as Element, ev as MouseEvent) + ); + } else { + return this.trigger('mouseout'); + } + } + + mouseover( + handler?: (this: Element, ev: MouseEvent) => unknown + ): jQuery { + if (handler) { + return this.on('mouseover', (ev: Event) => + handler.call(ev.target as Element, ev as MouseEvent) + ); + } else { + return this.trigger('mouseover'); + } + } + + mouseup(handler?: (this: Element, ev: MouseEvent) => unknown): jQuery { + if (handler) { + return this.on('mouseup', (ev: Event) => + handler.call(ev.target as Element, ev as MouseEvent) + ); + } else { + return this.trigger('mouseup'); + } + } + + next(): jQuery { + const nextElements = this.elements + .map(el => el.nextElementSibling) + .filter(el => el != null) as Element[]; + return this.newContext(nextElements); + } + + parent(): jQuery { + const parentElements = this.elements + .map(element => element.parentElement) + .filter(el => el !== null) as Element[]; + return this.newContext(parentElements); + } + + position(): { top: number; left: number } | undefined { + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + const el = this.elements[0]; + return { + top: el.offsetTop, + left: el.offsetLeft, + }; + } + return undefined; + } + + prepend(content: string | Element | jQuery): jQuery { + this.elements.forEach((el, index) => { + if (typeof content === 'string') { + el.insertAdjacentHTML('afterbegin', content); + } else if (content instanceof Element) { + if (index === this.elements.length - 1) { + el.insertBefore(content, el.firstChild); + } else { + const clonedContent = content.cloneNode(true); + el.insertBefore(clonedContent, el.firstChild); + } + } else if (content instanceof jQuery) { + content.elements.forEach((contentEl, contentIndex) => { + if ( + index === this.elements.length - 1 && + contentIndex === content.elements.length - 1 + ) { + el.insertBefore(contentEl, el.firstChild); + } else { + const clonedContentEl = contentEl.cloneNode(true); + el.insertBefore(clonedContentEl, el.firstChild); + } + }); + } + }); + return this; + } + + prependTo(target: string | Element | jQuery): jQuery { + if (typeof target === 'string') { + const targets = document.querySelectorAll(target); + targets.forEach((t, targetIndex) => { + this.elements.forEach((el, elIndex) => { + if ( + targetIndex === targets.length - 1 && + elIndex === this.elements.length - 1 + ) { + t.insertBefore(el, t.firstChild); + } else { + const clonedEl = el.cloneNode(true); + t.insertBefore(clonedEl, t.firstChild); + this.elements.push(clonedEl as Element); // Add cloned nodes to our elements + } + }); + }); + } else if (target instanceof Element) { + this.elements.forEach((el, index) => { + if (index === this.elements.length - 1) { + target.insertBefore(el, target.firstChild); + } else { + const clonedEl = el.cloneNode(true); + target.insertBefore(clonedEl, target.firstChild); + this.elements.push(clonedEl as Element); // Add cloned nodes to our elements + } + }); + } else if (target instanceof jQuery) { + target.elements.forEach((t, targetIndex) => { + this.elements.forEach((el, elIndex) => { + if ( + targetIndex === target.elements.length - 1 && + elIndex === this.elements.length - 1 + ) { + t.insertBefore(el, t.firstChild); + } else { + const clonedEl = el.cloneNode(true); + t.insertBefore(clonedEl, t.firstChild); + this.elements.push(clonedEl as Element); // Add cloned nodes to our elements + } + }); + }); + } + return this; + } + + prev(): jQuery { + const prevElements = this.elements + .map(el => el.previousElementSibling) + .filter(el => el != null) as Element[]; + return this.newContext(prevElements); + } + + prop( + propertyName: keyof Element, + value?: T + ): jQuery | T | undefined { + if (value === undefined) { + // Acting as a getter + return this.elements.length > 0 + ? (this.elements[0][propertyName] as T) ?? undefined + : undefined; + } else { + // Acting as a setter + this.elements.forEach(el => { + // proper solution would have been to somehow type propertyName + // as a non readonly keyof, which is complex + el[propertyName as 'innerHTML'] = value as string; + }); + return this; + } + } + + ready(fn: () => void): void { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + remove(): jQuery { + this.elements.forEach(el => { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + }); + // After removing elements from the DOM, clear the elements array + this.elements = []; + return this; + } + + removeClass(className: string): jQuery { + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + el.classList.remove(className); + } + }); + return this; + } + + resize(handler?: (this: Element, ev: Event) => unknown): jQuery { + if (handler) { + return this.on( + 'resize', + handler as (this: Element, ev: Event) => unknown + ); + } else { + return this.trigger('resize'); + } + } + + scroll(handler?: (this: Element, ev: UIEvent) => unknown): jQuery { + if (handler) { + return this.on( + 'scroll', + handler as (this: Element, ev: Event) => unknown + ); + } else { + return this.trigger('scroll'); + } + } + + scrollLeft(value?: number): jQuery | number { + if (value === undefined) { + // Acting as a getter + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + return this.elements[0].scrollLeft; + } + return 0; + } else { + // Acting as a setter + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + el.scrollLeft = value; + } + }); + return this; + } + } + + scrollTop(value?: number): jQuery | number { + if (value === undefined) { + // Acting as a getter + return this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ? this.elements[0].scrollTop + : 0; + } else { + // Acting as a setter + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + el.scrollTop = value; + } + }); + return this; + } + } + + select(handler?: (this: Element, ev: Event) => unknown): jQuery { + if (handler) { + return this.on('select', handler); + } else { + return this.trigger('select'); + } + } + + serialize(): string { + return this.elements + .filter( + el => + el instanceof HTMLInputElement || + el instanceof HTMLSelectElement || + el instanceof HTMLTextAreaElement + ) + .map( + el => + encodeURIComponent( + ( + el as + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + ).name + ) + + '=' + + encodeURIComponent( + ( + el as + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + ).value + ) + ) + .join('&'); + } + + serializeArray(): { name: string; value: string }[] { + return this.elements + .filter( + el => + el instanceof HTMLInputElement || + el instanceof HTMLSelectElement || + el instanceof HTMLTextAreaElement + ) + .map(el => ({ + name: ( + el as + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + ).name, + value: ( + el as + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + ).value, + })); + } + + show(): jQuery { + this.elements + .filter(el => el instanceof HTMLElement) + .forEach(element => { + (element as HTMLElement).style.display = 'initial'; + }); + return this; + } + + slideDown(): jQuery { + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + el.style.display = ''; + } + }); + return this; + } + + slideToggle(): jQuery { + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + el.style.display = + el.style.display === 'none' ? '' : 'none'; + } + }); + return this; + } + + slideUp(): jQuery { + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + el.style.display = 'none'; + } + }); + return this; + } + + stop(): jQuery { + // Since animations are not supported, stop does nothing + console.warn('stop() has no effect in mock-jQuery.'); + return this; + } + + submit(): jQuery { + if (this.elements[0] instanceof HTMLFormElement) { + this.elements[0].submit(); + } + return this; + } + + text(value?: string): jQuery | string { + if (value === undefined) { + // Acting as a getter + return this.elements.map(el => el.textContent).join(''); + } else { + // Acting as a setter + this.elements.forEach(el => { + el.textContent = value; + }); + return this; + } + } + + toggle(): jQuery { + this.elements.forEach(el => { + if (el instanceof HTMLElement) { + const style = getComputedStyle(el); + if (style.display === 'none') { + el.style.display = ''; + } else { + el.style.display = 'none'; + } + } + }); + return this; + } + + trigger(eventType: string): jQuery { + this.elements.forEach(el => { + const event = new Event(eventType); + el.dispatchEvent(event); + }); + return this; + } + + val(value?: string): jQuery | string { + if (value === undefined) { + // Getter: Return the value of the first element + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLInputElement + ) { + return (this.elements[0] as HTMLInputElement).value; + } + return ''; // Return empty string if no elements or not an input element + } else { + // Setter: Set the value for all input elements + this.elements + .filter(el => el instanceof HTMLInputElement) + .forEach(element => { + (element as HTMLInputElement).value = value; + }); + return this; + } + } + + width(): number | undefined { + if ( + this.elements.length > 0 && + this.elements[0] instanceof HTMLElement + ) { + return this.elements[0].clientWidth; + } + return undefined; + } + + wrap(wrapperString: string): jQuery { + this.elements.forEach(element => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = wrapperString; + const wrapperInner = wrapper.children[0]; + if (element.parentNode && wrapperInner) { + element.parentNode.insertBefore(wrapperInner, element); + wrapperInner.appendChild(element); + } + }); + return this; + } + } + + function jQueryFn(selector: string, context?: Context): jQuery; + function jQueryFn(selector: Element): jQuery; + function jQueryFn(selector: Element[]): jQuery; + function jQueryFn( + selector: string | Element | Element[], + context?: Context + ): jQuery; + function jQueryFn( + selector: string | Element | Element[], + context?: Context + ): jQuery { + return new Proxy( + new jQuery(selector, context), + // proxy handler + { + get: function (target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } else if (!isNaN(Number(prop))) { + return target.get(Number(prop)); + } else { + console.error( + `Attempted to access jQuery property: \`${String( + prop + )}\` but it was not emulated.` + ); + return undefined; + } + }, + } + ); + } + + jQueryFn.ajax = (settings: { + url: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + data?: T; + headers?: HeadersInit; + }): Promise => { + const { url, method = 'GET', data, headers } = settings; + const options: RequestInit = { + method, + headers, + body: undefined, + }; + + if (data) { + if (method === 'GET') { + console.warn( + 'Data payload with GET request might not be sent by some browsers.' + ); + } else { + if (typeof data === 'object') { + options.body = JSON.stringify(data); + options.headers = { + ...headers, + 'Content-Type': 'application/json', + }; + } else { + options.body = data; // Assuming stringified data or FormData + } + } + } + + return fetch(url, options); + }; + + jQueryFn.data = function ( + element: Element, + key: string, + value?: T + ): T { + const dataKey = `jQueryData-${key}`; + if (value === undefined) { + // Getter + return (element as unknown as Record)[dataKey]; + } else { + // Setter + (element as unknown as Record)[dataKey] = value; + return value; + } + }; + + jQueryFn.each = function ( + objectOrArray: T[] | Record, + callback: (indexOrKey: number | string, elementOrValue: T) => void + ): void { + if (Array.isArray(objectOrArray)) { + objectOrArray.forEach((element, index) => { + callback.call(element, index, element); + }); + } else { + Object.keys(objectOrArray).forEach(key => { + callback.call(objectOrArray[key], key, objectOrArray[key]); + }); + } + }; + + jQueryFn.extend = function (...args: unknown[]): Record { + let target: unknown = args[0] || {}; + let i = 1; + const length = args.length; + let deep = false; + let options: unknown, + name: string, + src: unknown, + copy: unknown, + copyIsArray: unknown, + clone: unknown; + + if (typeof target === 'boolean') { + deep = target; + target = args[i] || {}; + i++; + } + + if (typeof target !== 'object' && typeof target !== 'function') { + target = {}; + } + + if (i === length) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + target = this; + i--; + } + + for (; i < length; i++) { + if ((options = args[i]) != null) { + for (name in options) { + src = (target as Record)[name]; + copy = (options as Record)[name]; + + if (target === copy) { + continue; + } + + if ( + deep && + copy && + (typeof copy === 'object' || + (copyIsArray = Array.isArray(copy))) + ) { + if (copyIsArray) { + copyIsArray = false; + clone = src && Array.isArray(src) ? src : []; + } else { + clone = src && typeof src === 'object' ? src : {}; + } + + (target as Record)[name] = + jQueryFn.extend(deep, clone, copy); + } else if (copy !== undefined) { + (target as Record)[name] = copy; + } + } + } + } + + return target as Record; + }; + + jQueryFn.fn = jQuery.prototype as unknown as Record; + + jQueryFn.get = ( + url: string, + data?: (...args: unknown[]) => unknown, + success?: (data: unknown) => void, + _dataType?: string + ): Promise => { + if (typeof data === 'function') { + success = data; + data = undefined; + } + return jQueryFn + .ajax({ + url, + method: 'GET', + data, + }) + .then(response => { + if (success) success(response); + return response; + }); + }; + + jQueryFn.getJSON = ( + url: string, + data?: (...args: unknown[]) => unknown, + success?: (data: unknown) => void + ): Promise => { + return jQueryFn.get( + url, + data, + response => { + const jsonData = JSON.parse(response as string); + if (success) success(jsonData); + }, + 'json' + ); + }; + + jQueryFn.getScript = ( + url: string, + success?: () => void + ): Promise => { + return jQueryFn.get(url, undefined, response => { + // eslint-disable-next-line no-eval + eval(response as string); + if (success) success(); + }); + }; + + jQueryFn.post = ( + url: string, + data?: TData | ((data: TResponse) => void), + success?: (data: TResponse) => void, + _dataType?: string + ): Promise => { + if (typeof data === 'function') { + success = data as (data: TResponse) => void; + data = undefined; + } + return jQueryFn + .ajax({ + url, + method: 'POST', + data: data as TData, + }) + .then(response => { + if (success) success(response as unknown as TResponse); + return response; + }); + }; + + jQueryFn.removeData = function (element: Element, key: string): void { + const dataKey = `jQueryData-${key}`; + if ( + Object.prototype.hasOwnProperty.call( + element as unknown as Record, + dataKey + ) + ) { + delete (element as unknown as Record)[dataKey]; + } + }; + + jQueryFn.Widget = class { + // eslint-disable-next-line @typescript-eslint/no-empty-function + _create() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + _init() {} + destroy(this: Record) { + if (this.element) { + jQueryFn.removeData( + this.element as Element, + this.widgetFullName as string + ); + } + } + option( + this: { + options: Record; + [key: string]: unknown; + }, + key: string | Record, + value?: unknown + ) { + const options = key; + if (arguments.length === 0) { + return { ...this.options }; + } + + if (typeof key === 'string') { + if (value === undefined) { + return this.options[key]; + } + this.options[key] = value; + } else { + this.options = { + ...this.options, + ...(options as Record), + }; + } + + return this; + } + }; + + Object.assign(jQueryFn.Widget.prototype, { + widgetName: '', + widgetFullName: '', + widgetEventPrefix: '', + options: {}, + }); + + jQueryFn.widget = ( + name: string, + prototype: Record + ): void => { + const [namespace, methodName] = name.split('.'); + const fullName = `${namespace}-${methodName}`; + + const jQueryNs = jQueryFn as unknown as { + [namespace: string]: { + [methodName: string]: new ( + options?: string | Record, + element?: jQuery + ) => unknown; + }; + }; + + if (!jQueryNs[namespace]) { + jQueryNs[namespace] = {}; + } + + jQueryNs[namespace][methodName] = class extends jQueryFn.Widget { + constructor( + private options: string | Record = {}, + private element?: jQuery + ) { + super(); + this._create(); + } + }; + + Object.assign( + jQueryNs[namespace][methodName].prototype, + { widgetName: name, widgetFullName: fullName }, + prototype + ); + + jQueryFn.fn[methodName] = function ( + this: jQuery, + options?: string | Record, + ...args: unknown[] + ): unknown { + const isMethodCall = typeof options === 'string'; + + // Allow instantiation without "new" keyword + if (isMethodCall) { + let returnValue: unknown; + + this.each((index, element) => { + const instance = jQueryFn.data(element, fullName) as Record< + string, + unknown + >; + if ( + typeof options === 'string' && + instance && + typeof instance[options] === 'function' + ) { + const method = ( + instance[options] as (...args: unknown[]) => unknown + ).bind(instance); + const methodValue = method(...args); + if ( + methodValue !== undefined && + methodValue !== instance + ) { + returnValue = methodValue; + return false; + } + return true; + } + return true; + }); + + return returnValue; + } else { + return this.each(function (this: Element) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const element = this; + if (jQueryFn.data(element, fullName)) { + const instance = jQueryFn.data( + element, + fullName + ) as Record; + if (typeof instance.option === 'function') { + instance.option(options || {}); + } + if (typeof instance._init === 'function') { + instance._init(); + } + } else { + jQueryFn.data( + element, + fullName, + new jQueryNs[namespace][methodName]( + options, + jQueryFn(element) + ) + ); + } + }); + } + }; + }; + + // plugins + applyTypedInput(RED, jQueryFn); + applyEditableList(RED, jQueryFn); + + // Mock Plugins + jQueryFn.widget('mocked.spinner', {}); + + return jQueryFn; +}; diff --git a/packages/flow-client/src/app/red/mock-popover.ts b/packages/flow-client/src/app/red/mock-popover.ts new file mode 100644 index 0000000..8bd7841 --- /dev/null +++ b/packages/flow-client/src/app/red/mock-popover.ts @@ -0,0 +1,41 @@ +import { createMockJquery } from './mock-jquery'; + +export const createMockPopover = ( + jQuery: ReturnType +) => { + const createPopover = () => { + const response = { + get element() { + const div = document.createElement('div'); + return div; + }, + setContent: function (_content: unknown) { + return response; + }, + open: function (_instant: unknown) { + return response; + }, + close: function (_instant: unknown) { + return response; + }, + move: function (_options: unknown) { + return; + }, + }; + return response; + }; + return { + create: createPopover, + menu: () => ({ + options: () => undefined, + show: () => undefined, + hide: () => undefined, + }), + tooltip: () => createPopover(), + panel: () => ({ + container: jQuery([]), + show: () => undefined, + hide: () => undefined, + }), + }; +}; diff --git a/packages/flow-client/src/app/red/mock-red.ts b/packages/flow-client/src/app/red/mock-red.ts new file mode 100644 index 0000000..29a5bbb --- /dev/null +++ b/packages/flow-client/src/app/red/mock-red.ts @@ -0,0 +1,246 @@ +import { RedEditorType, createMockEditor } from './mock-editor'; +import { Context, createMockJquery } from './mock-jquery'; +import { createMockPopover } from './mock-popover'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { createRedTabs } from './red-tabs'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { createRedUtils } from './red-utils'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { createRedI18n } from './red-i18n'; + +export type RedType = { + nodes: { + registerType(..._args: unknown[]): unknown; + }; + editor: RedEditorType; + utils: { + sanitize(input: string): string; + validateTypedProperty(..._args: unknown[]): unknown; + }; + events: { + on(..._args: unknown[]): unknown; + }; + settings: { + get(..._args: unknown[]): unknown; + }; + text: { + bidi: { + setTextDirection(..._args: unknown[]): unknown; + }; + }; + _(messagePath: string, ..._args: unknown[]): string; + $: ReturnType; + tabs: ReturnType; + popover: ReturnType; + validators: { + number(..._args: unknown[]): unknown; + regex(..._args: unknown[]): unknown; + typedInput(..._args: unknown[]): unknown; + }; +}; + +export const createMockRed = (jQueryContext: Context = window.document) => { + const initialized = { + editor: false, + }; + + const RED = new Proxy( + // target RED object + { + nodes: { + registerType(..._args: unknown[]): unknown { + // not implemented + return undefined; + }, + }, + validators: { + number: function (blankAllowed: boolean, _mopt: unknown) { + return function (v: string, opt: Record) { + if (blankAllowed && (v === '' || v === undefined)) { + return true; + } + if (v !== '') { + if ( + /^NaN$|^[+-]?[0-9]*\.?[0-9]*([eE][-+]?[0-9]+)?$|^[+-]?(0b|0B)[01]+$|^[+-]?(0o|0O)[0-7]+$|^[+-]?(0x|0X)[0-9a-fA-F]+$/.test( + v + ) + ) { + return true; + } + if (/^\${[^}]+}$/.test(v)) { + // Allow ${ENV_VAR} value + return true; + } + } + if (!isNaN(v as unknown as number)) { + return true; + } + if (opt && opt.label) { + return RED._('validator.errors.invalid-num-prop', { + prop: opt.label, + }); + } + return opt + ? RED._('validator.errors.invalid-num') + : false; + }; + }, + regex: function (re: RegExp, _mopt: Record) { + return function (v: string, opt: Record) { + if (re.test(v)) { + return true; + } + if (opt && opt.label) { + return RED._( + 'validator.errors.invalid-regex-prop', + { + prop: opt.label, + } + ); + } + return opt + ? RED._('validator.errors.invalid-regexp') + : false; + }; + }, + typedInput: function ( + ptypeName: string | Record, + isConfig: boolean, + _mopt: Record + ) { + let options: Record; + if (typeof ptypeName === 'string') { + options = {}; + options.typeField = ptypeName; + options.isConfig = isConfig; + options.allowBlank = false; + } + + return function (v: string, opt: Record) { + let ptype = options.type; + if (!ptype && options.typeField) { + ptype = + ( + document.querySelector( + '#node-' + + (options.isConfig + ? 'config-' + : '') + + 'input-' + + options.typeField + ) as HTMLInputElement + )?.value || + RED.validators[ + options.typeField as keyof typeof RED.validators + ]; + } + if (options.allowBlank && v === '') { + return true; + } + if (options.allowUndefined && v === undefined) { + return true; + } + const result = RED.utils.validateTypedProperty( + v, + ptype, + opt + ); + if (result === true || opt) { + // Valid, or opt provided - return result as-is + return result; + } + // No opt - need to return false for backwards compatibilty + return false; + }; + }, + }, + events: { + on(event: string, _handler: (...args: unknown[]) => unknown) { + console.debug( + 'Ignoring node event handler for event: ', + event + ); + }, + }, + library: { + create: () => undefined, + }, + settings: { + get(name: string, defaultValue: unknown) { + console.debug( + 'Returning default Node-RED value for setting: ', + name + ); + return defaultValue; + }, + }, + text: { + bidi: { + setTextDirection: () => undefined, + enforceTextDirectionWithUCC: (value: unknown) => value, + resolveBaseTextDir: () => 'ltr', + prepareInput: () => undefined, + }, + format: { + getHtml: function (..._args: unknown[]) { + return {}; + }, + attach: function (..._args: unknown[]) { + return true; + }, + }, + }, + tray: { + resize: () => undefined, + }, + _: undefined as unknown as (...args: unknown[]) => string, + $: undefined as unknown as ReturnType, + tabs: undefined as unknown as ReturnType, + popover: undefined as unknown as ReturnType< + typeof createMockPopover + >, + get editor() { + if (!initialized.editor) { + initialized.editor = true; + RedEditor.init(); + } + return RedEditor; + }, + utils: createRedUtils(), + } as RedType, + // proxy handler + { + get: function (target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } else { + console.error( + `Attempted to access RED property: \`${String( + prop + )}\` but it was not emulated.` + ); + return undefined; + } + }, + } + ); + + const jQuery = createMockJquery(RED); + RED.$ = ((selector: string, context: Context = jQueryContext) => + jQuery(selector, context)) as typeof jQuery; + Object.assign(RED.$, jQuery); + + const i18n = createRedI18n(RED, RED.$); + i18n.init(); + RED._ = i18n._; + + RED.tabs = createRedTabs(RED, RED.$); + RED.popover = createMockPopover(RED.$); + + const RedEditor = createMockEditor(RED, RED.$); + + return RED; +}; diff --git a/packages/flow-client/src/app/red/red-code-editor.js b/packages/flow-client/src/app/red/red-code-editor.js new file mode 100644 index 0000000..ef114f9 --- /dev/null +++ b/packages/flow-client/src/app/red/red-code-editor.js @@ -0,0 +1,130 @@ +import { createRedMonaco } from './red-monaco'; + +/** + * Copyright JS Foundation and other contributors, http://js.foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +/** + * @namespace RED.editor.codeEditor + */ +export const createRedCodeEditor = (RED, $) => { + const MONACO = 'monaco'; + const ACE = 'ace'; + const defaultEditor = MONACO; + const DEFAULT_SETTINGS = { lib: defaultEditor, options: {} }; + var selectedCodeEditor = null; + var initialised = false; + + function init() { + var codeEditorSettings = RED.editor.codeEditor.settings; + var editorChoice = codeEditorSettings.lib === MONACO ? MONACO : ACE; + try { + var browser = RED.utils.getBrowserInfo(); + selectedCodeEditor = RED.editor.codeEditor[editorChoice]; + //fall back to default code editor if there are any issues + if ( + !selectedCodeEditor || + (editorChoice === MONACO && (browser.ie || !window.monaco)) + ) { + selectedCodeEditor = RED.editor.codeEditor[defaultEditor]; + } + initialised = selectedCodeEditor.init(); + } catch (error) { + selectedCodeEditor = null; + console.warn( + "Problem initialising '" + editorChoice + "' code editor", + error + ); + } + if (!initialised) { + selectedCodeEditor = RED.editor.codeEditor[defaultEditor]; + initialised = selectedCodeEditor.init(); + } + + $( + '


' + ).appendTo('#red-ui-editor'); + $('#red-ui-image-drop-target').hide(); + } + + function create(options) { + //TODO: (quandry - for consideration) + // Below, I had to create a hidden element if options.id || options.element is not in the DOM + // I have seen 1 node calling `this.editor = RED.editor.createEditor()` with an + // invalid (non existing html element selector) (e.g. node-red-contrib-components does this) + // This causes monaco to throw an error when attempting to hook up its events to the dom & the rest of the 'oneditperapre' + // code is thus skipped. + // In ACE mode, creating an ACE editor (with an invalid ID) allows the editor to be created (but obviously there is no UI) + // Because one (or more) contrib nodes have left this bad code in place, how would we handle this? + // For compatibility, I have decided to create a hidden element so that at least an editor is created & errors do not occur. + // IMO, we should warn and exit as it is a coding error by the contrib author. + + if (!options) { + console.warn('createEditor() options are missing'); + options = {}; + } + + var editor = null; + if (this.editor.type === MONACO) { + // compatibility (see above note) + if (!options.element && !options.id) { + options.id = 'node-backwards-compatability-dummy-editor'; + } + options.element = options.element || $('#' + options.id)[0]; + if (!options.element) { + console.warn( + 'createEditor() options.element or options.id is not valid', + options + ); + $('#dialog-form').append( + '