Browse Source

conform to observer pattern

main
Stephanie Gredell 5 months ago
parent
commit
03cdcbe767
  1. 118
      static/app.js
  2. 20
      static/connection.js
  3. 13
      static/node.js
  4. 411
      static/observers.js

118
static/app.js

@ -12,6 +12,7 @@ import './plugins/datapipeline.js';
import './plugins/monitorAlerting.js'; import './plugins/monitorAlerting.js';
import './plugins/thirdPartyService.js'; import './plugins/thirdPartyService.js';
import { PluginRegistry } from './pluginRegistry.js'; import { PluginRegistry } from './pluginRegistry.js';
import { initializeObservers } from './observers.js';
export class CanvasApp { export class CanvasApp {
constructor() { constructor() {
@ -55,6 +56,12 @@ export class CanvasApp {
this._maxReconnectDelay = 15000; this._maxReconnectDelay = 15000;
this._reconnectTimer = null; this._reconnectTimer = null;
// Initialize observer system (alongside existing event handling)
const observers = initializeObservers(this.nodePropsPanel, this.propsSaveBtn);
this.propertiesPanelSubject = observers.propertiesPanel;
this.nodeSelectionSubject = observers.nodeSelection;
this.connectionModeSubject = observers.connectionMode;
this.initEventHandlers(); this.initEventHandlers();
} }
@ -77,13 +84,16 @@ export class CanvasApp {
this.arrowMode = !this.arrowMode; this.arrowMode = !this.arrowMode;
if (this.arrowMode) { if (this.arrowMode) {
this.arrowToolBtn.classList.add('active'); this.arrowToolBtn.classList.add('active');
this.hidePropsPanel(); // Use observer to notify that arrow mode is enabled (will hide props panel)
this.connectionModeSubject.notifyConnectionModeChanged(true);
} else { } else {
this.arrowToolBtn.classList.remove('active'); this.arrowToolBtn.classList.remove('active');
if (this.connectionStart) { if (this.connectionStart) {
this.connectionStart.group.classList.remove('selected'); this.connectionStart.group.classList.remove('selected');
this.connectionStart = null; this.connectionStart = null;
} }
// Use observer to notify that arrow mode is disabled
this.connectionModeSubject.notifyConnectionModeChanged(false);
} }
}); });
this.startChatBtn.addEventListener('click', () => { this.startChatBtn.addEventListener('click', () => {
@ -245,9 +255,17 @@ export class CanvasApp {
// Don't hide props panel if clicking on it // Don't hide props panel if clicking on it
if (!this.nodePropsPanel.contains(e.target)) { if (!this.nodePropsPanel.contains(e.target)) {
this.hidePropsPanel(); // Use observer to notify that properties panel should be closed
if (this.selectedNode) {
this.propertiesPanelSubject.notifyPropertiesPanelClosed(this.selectedNode);
}
}
// Use observer to notify that current node should be deselected
if (this.selectedNode) {
this.nodeSelectionSubject.notifyNodeDeselected(this.selectedNode);
this.selectedNode = null;
} }
this.clearSelection();
}); });
this.propsSaveBtn.addEventListener('click', () => { this.propsSaveBtn.addEventListener('click', () => {
@ -258,7 +276,6 @@ export class CanvasApp {
const plugin = PluginRegistry.get(node.type); const plugin = PluginRegistry.get(node.type);
if (!plugin || !plugin.props) { if (!plugin || !plugin.props) {
this.hidePropsPanel();
return; return;
} }
@ -280,8 +297,6 @@ export class CanvasApp {
node.updateLabel(value); node.updateLabel(value);
} }
} }
this.hidePropsPanel();
}); });
// Prevent props panel from closing when clicking inside it // Prevent props panel from closing when clicking inside it
@ -310,95 +325,14 @@ export class CanvasApp {
}); });
this.selectedNode = null; this.selectedNode = null;
this.activeNode = null; this.activeNode = null;
this.hidePropsPanel();
} }
} }
}); });
} }
showPropsPanel(node) {
this.activeNode = node;
const plugin = PluginRegistry.get(node.type);
const panel = this.nodePropsPanel;
if (!plugin || this.arrowMode) {
this.hidePropsPanel();
return;
}
// Get the node's actual screen position using getBoundingClientRect
const nodeRect = node.group.getBoundingClientRect();
const containerRect = this.canvasContainer.getBoundingClientRect();
const panelWidth = 220; // From CSS: #node-props-panel width
const panelHeight = 400; // Estimated height for boundary checking
// Try to position dialog to the right of the node
let dialogX = nodeRect.right + 10;
let dialogY = nodeRect.top;
// Check if dialog would go off the right edge of the screen
if (dialogX + panelWidth > window.innerWidth) {
// Position to the left of the node instead
dialogX = nodeRect.left - panelWidth - 10;
}
// Check if dialog would go off the bottom of the screen
if (dialogY + panelHeight > window.innerHeight) {
// Move up to keep it visible
dialogY = window.innerHeight - panelHeight - 10;
}
// Ensure dialog doesn't go above the top of the screen
if (dialogY < 10) {
dialogY = 10;
}
panel.style.left = dialogX + 'px';
panel.style.top = dialogY + 'px';
// Hide all groups first
const allGroups = panel.querySelectorAll('.prop-group, #label-group, #compute-group, #lb-group');
allGroups.forEach(g => g.style.display = 'none');
const shownGroups = new Set();
for (const prop of plugin.props) {
const group = panel.querySelector(`[data-group='${prop.group}']`);
const input = panel.querySelector(`[name='${prop.name}']`);
// Show group once
if (group && !shownGroups.has(group)) {
group.style.display = 'block';
shownGroups.add(group);
}
// Set value
if (input) {
input.value = node.props[prop.name] ?? prop.default;
}
}
this.propsSaveBtn.disabled = false;
panel.style.display = 'block';
// Trigger smooth animation
setTimeout(() => {
panel.classList.add('visible');
}, 10);
}
hidePropsPanel() {
const panel = this.nodePropsPanel;
panel.classList.remove('visible');
// Hide after animation completes
setTimeout(() => {
panel.style.display = 'none';
}, 200);
this.propsSaveBtn.disabled = true;
this.activeNode = null;
}
updateConnectionsFor(movedNode) { updateConnectionsFor(movedNode) {
this.connections.forEach(conn => { this.connections.forEach(conn => {
@ -408,17 +342,7 @@ export class CanvasApp {
}); });
} }
clearSelection() {
if (this.selectedConnection) {
this.selectedConnection.deselect();
this.selectedConnection = null;
}
if (this.selectedNode) {
this.selectedNode.deselect();
this.selectedNode = null;
}
}
exportDesign() { exportDesign() {
const nodes = this.placedComponents const nodes = this.placedComponents

20
static/connection.js

@ -41,7 +41,15 @@ export class Connection {
this.selected = false; this.selected = false;
this.line.addEventListener('click', (e) => { this.line.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.app.clearSelection(); // Clear node selection via observer
if (this.app.selectedNode) {
this.app.nodeSelectionSubject.notifyNodeDeselected(this.app.selectedNode);
this.app.selectedNode = null;
}
// Clear any previously selected connection
if (this.app.selectedConnection) {
this.app.selectedConnection.deselect();
}
this.select(); this.select();
}); });
@ -117,7 +125,15 @@ export class Connection {
} }
select() { select() {
this.app.clearSelection(); // Clear node selection via observer
if (this.app.selectedNode) {
this.app.nodeSelectionSubject.notifyNodeDeselected(this.app.selectedNode);
this.app.selectedNode = null;
}
// Clear any previously selected connection
if (this.app.selectedConnection) {
this.app.selectedConnection.deselect();
}
this.selected = true; this.selected = true;
this.line.setAttribute('stroke', '#007bff'); this.line.setAttribute('stroke', '#007bff');
this.line.setAttribute('stroke-width', 3); this.line.setAttribute('stroke-width', 3);

13
static/node.js

@ -62,15 +62,17 @@ export class ComponentNode {
if (app.arrowMode) { if (app.arrowMode) {
Connection.handleClick(this, app); Connection.handleClick(this, app);
} else { } else {
app.clearSelection(); // Use observer to notify node selection
this.select(); app.nodeSelectionSubject.notifyNodeSelected(this);
app.selectedNode = this; // Keep app state in sync for now
} }
}); });
this.group.addEventListener('dblclick', (e) => { this.group.addEventListener('dblclick', (e) => {
e.stopPropagation(); e.stopPropagation();
if (!app.arrowMode) { if (!app.arrowMode) {
app.showPropsPanel(this); // Use observer pattern instead of direct call
app.propertiesPanelSubject.notifyPropertiesPanelRequested(this);
} }
}); });
@ -144,7 +146,10 @@ export class ComponentNode {
} }
select() { select() {
this.app.clearSelection(); // Use observer to clear previous selection and select this node
if (this.app.selectedNode && this.app.selectedNode !== this) {
this.app.nodeSelectionSubject.notifyNodeDeselected(this.app.selectedNode);
}
this.group.classList.add('selected'); this.group.classList.add('selected');
this.app.selectedNode = this; this.app.selectedNode = this;
} }

411
static/observers.js

@ -0,0 +1,411 @@
/**
* Dedicated Observer Pattern Implementation
*
* Each observer is dedicated to a particular concern and is type-safe.
* This provides clean separation of concerns and maintainable event handling.
*/
import { PluginRegistry } from './pluginRegistry.js';
/**
* NodeSelectionObserver - Dedicated to node selection events only
*/
export class NodeSelectionObserver {
constructor() {
this.observers = [];
}
// Add a specific observer for node selection changes
addObserver(observer) {
if (typeof observer.onNodeSelected !== 'function' ||
typeof observer.onNodeDeselected !== 'function') {
throw new Error('Observer must implement onNodeSelected and onNodeDeselected methods');
}
this.observers.push(observer);
}
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
// Notify all observers about node selection
notifyNodeSelected(node) {
for (const observer of this.observers) {
observer.onNodeSelected(node);
}
}
// Notify all observers about node deselection
notifyNodeDeselected(node) {
for (const observer of this.observers) {
observer.onNodeDeselected(node);
}
}
}
/**
* PropertiesPanelObserver - Dedicated to properties panel events only
*/
export class PropertiesPanelObserver {
constructor() {
this.observers = [];
}
addObserver(observer) {
if (typeof observer.onPropertiesPanelRequested !== 'function') {
throw new Error('Observer must implement onPropertiesPanelRequested method');
}
this.observers.push(observer);
}
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notifyPropertiesPanelRequested(node) {
for (const observer of this.observers) {
observer.onPropertiesPanelRequested(node);
}
}
notifyPropertiesPanelClosed(node) {
for (const observer of this.observers) {
if (observer.onPropertiesPanelClosed) {
observer.onPropertiesPanelClosed(node);
}
}
}
}
/**
* ConnectionModeObserver - Dedicated to connection/arrow mode events
*/
export class ConnectionModeObserver {
constructor() {
this.observers = [];
}
addObserver(observer) {
if (typeof observer.onConnectionModeChanged !== 'function') {
throw new Error('Observer must implement onConnectionModeChanged method');
}
this.observers.push(observer);
}
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notifyConnectionModeChanged(isEnabled) {
for (const observer of this.observers) {
observer.onConnectionModeChanged(isEnabled);
}
}
}
/**
* Properties Panel Manager - Implements observer interfaces
*/
export class PropertiesPanelManager {
constructor(panelElement, saveButton) {
this.panel = panelElement;
this.saveButton = saveButton;
this.activeNode = null;
this.setupDOMEventListeners();
}
// Implement the observer interface for properties panel events
onPropertiesPanelRequested(node) {
const plugin = PluginRegistry.get(node.type);
if (!plugin) {
this.hidePanel();
return;
}
this.showPanel(node, plugin);
}
onPropertiesPanelClosed(node) {
if (this.activeNode === node) {
this.hidePanel();
}
}
// Implement the observer interface for connection mode events
onConnectionModeChanged(isEnabled) {
if (isEnabled && this.activeNode) {
this.hidePanel();
}
}
// Implement the observer interface for node selection events
onNodeSelected(node) {
// Properties panel doesn't need to do anything special when nodes are selected
// The double-click handler takes care of showing the panel
}
onNodeDeselected(node) {
// When a node is deselected, close the properties panel if it's for that node
if (this.activeNode === node) {
this.hidePanel();
}
}
setupDOMEventListeners() {
this.saveButton.addEventListener('click', () => {
this.saveProperties();
});
this.panel.addEventListener('click', (e) => {
e.stopPropagation();
});
}
showPanel(node, plugin) {
this.activeNode = node;
// Calculate position for optimal placement
const nodeRect = node.group.getBoundingClientRect();
const panelWidth = 220;
const panelHeight = 400;
let dialogX = nodeRect.right + 10;
let dialogY = nodeRect.top;
if (dialogX + panelWidth > window.innerWidth) {
dialogX = nodeRect.left - panelWidth - 10;
}
if (dialogY + panelHeight > window.innerHeight) {
dialogY = window.innerHeight - panelHeight - 10;
}
if (dialogY < 10) {
dialogY = 10;
}
this.panel.style.left = dialogX + 'px';
this.panel.style.top = dialogY + 'px';
// Hide all groups first
const allGroups = this.panel.querySelectorAll('.prop-group, #label-group, #compute-group, #lb-group');
allGroups.forEach(g => g.style.display = 'none');
const shownGroups = new Set();
// Set up properties based on plugin definition
for (const prop of plugin.props) {
const group = this.panel.querySelector(`[data-group='${prop.group}']`);
const input = this.panel.querySelector(`[name='${prop.name}']`);
// Show group once
if (group && !shownGroups.has(group)) {
group.style.display = 'block';
shownGroups.add(group);
}
// Set value
if (input) {
input.value = node.props[prop.name] ?? prop.default;
}
}
this.saveButton.disabled = false;
this.panel.style.display = 'block';
setTimeout(() => {
this.panel.classList.add('visible');
}, 10);
}
hidePanel() {
if (!this.activeNode) return;
this.panel.classList.remove('visible');
setTimeout(() => {
this.panel.style.display = 'none';
}, 200);
this.activeNode = null;
}
saveProperties() {
if (!this.activeNode) return;
const node = this.activeNode;
const panel = this.panel;
const plugin = PluginRegistry.get(node.type);
if (!plugin || !plugin.props) {
this.hidePanel();
return;
}
// Loop through plugin-defined props and update the node
for (const prop of plugin.props) {
const input = panel.querySelector(`[name='${prop.name}']`);
if (!input) continue;
let value;
if (prop.type === 'number') {
value = parseFloat(input.value);
if (isNaN(value)) value = prop.default ?? 0;
} else {
value = input.value;
}
node.props[prop.name] = value;
if (prop.name === 'label') {
node.updateLabel(value);
}
}
this.hidePanel();
}
}
/**
* Selection Manager - Implements node selection observer interface
*/
export class SelectionManager {
constructor() {
this.selectedNode = null;
this.selectedConnection = null;
}
// Implement the observer interface for node selection
onNodeSelected(node) {
this.clearSelection();
this.selectedNode = node;
node.select(); // Visual feedback
}
onNodeDeselected(node) {
if (this.selectedNode === node) {
node.deselect();
this.selectedNode = null;
}
}
clearSelection() {
if (this.selectedNode) {
this.selectedNode.deselect();
this.selectedNode = null;
}
if (this.selectedConnection) {
this.selectedConnection.deselect();
this.selectedConnection = null;
}
}
}
/**
* Initialize the observer system
*/
export function initializeObservers(nodePropsPanel, propsSaveBtn) {
// Create the specific observers (subjects)
const nodeSelectionSubject = new NodeSelectionObserver();
const propertiesPanelSubject = new PropertiesPanelObserver();
const connectionModeSubject = new ConnectionModeObserver();
// Create the specific observers (listeners)
const propertiesPanel = new PropertiesPanelManager(nodePropsPanel, propsSaveBtn);
const selectionManager = new SelectionManager();
// Wire them together - each observer registers for what it cares about
nodeSelectionSubject.addObserver(selectionManager);
nodeSelectionSubject.addObserver(propertiesPanel); // Properties panel cares about selection too
propertiesPanelSubject.addObserver(propertiesPanel);
connectionModeSubject.addObserver(propertiesPanel); // Panel hides when arrow mode enabled
// Return the subjects so the main app can notify them
return {
nodeSelection: nodeSelectionSubject,
propertiesPanel: propertiesPanelSubject,
connectionMode: connectionModeSubject
};
}
/**
* How the main CanvasApp would use these observers
*/
export class CanvasAppWithObservers {
constructor() {
// Initialize observers
const observers = initializeObservers();
this.nodeSelectionSubject = observers.nodeSelection;
this.propertiesPanelSubject = observers.propertiesPanel;
this.connectionModeSubject = observers.connectionMode;
this.selectedNode = null;
this.arrowMode = false;
this.setupEventListeners();
}
setupEventListeners() {
// Canvas click - clear selection
this.canvas.addEventListener('click', (e) => {
if (e.detail > 1) return; // Ignore double-clicks
if (this.selectedNode) {
this.nodeSelectionSubject.notifyNodeDeselected(this.selectedNode);
this.selectedNode = null;
}
});
// Arrow mode toggle
this.arrowToolBtn.addEventListener('click', () => {
this.arrowMode = !this.arrowMode;
this.connectionModeSubject.notifyConnectionModeChanged(this.arrowMode);
});
}
// When a node is double-clicked
onNodeDoubleClick(node) {
if (!this.arrowMode) {
this.propertiesPanelSubject.notifyPropertiesPanelRequested(node);
}
}
// When a node is single-clicked
onNodeSingleClick(node) {
if (this.selectedNode !== node) {
if (this.selectedNode) {
this.nodeSelectionSubject.notifyNodeDeselected(this.selectedNode);
}
this.selectedNode = node;
this.nodeSelectionSubject.notifyNodeSelected(node);
}
}
}
/**
* Key Benefits of This Approach:
*
* 1. TYPE SAFETY: Each observer has a specific interface
* 2. SINGLE RESPONSIBILITY: Each observer handles ONE concern
* 3. NO MAGIC STRINGS: No event type constants that can be mistyped
* 4. COMPILE-TIME CHECKING: TypeScript/IDE can validate observer interfaces
* 5. FOCUSED: PropertiesPanelObserver only knows about properties panels
* 6. TESTABLE: Each observer can be tested with mock implementations
*
* This approach is more verbose but much safer and clearer about
* what each component is responsible for.
*/
Loading…
Cancel
Save