Responding to context menu click

We are considering purchasing GoJS so we have started evaluating the product. Because we are using Angular, we want to use the gojs-angular package and see if we can implement some of our requirements.

We are trying to reproduce the code demonstrated here Simple Block Editor but using the gojs-angular wrapper.

So we downloaded the gojs-angular-basic sample and added the relevant code to show a context menu when clicking on a shape.

We can display the menu with a list of figure buttons to change the shape of the selected node.
ClickFunction gets called and we see that m.set(obj.part.adornedPart.data, propname, value) sets the correct values.

However, the shape in the diagram does not get updated. We believe this happens because angular change detection does not get fired. What is the correct way to do this ?

Also, we have added a TextBlock menu item ‘edit task’. In the click event, we are just logging the click event, which works fine. But how would we call a method located in my actual component ?

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  encapsulation: ViewEncapsulation.None
})
export class AppComponent {

  @ViewChild('myDiagram', { static: true }) public myDiagramComponent: DiagramComponent;
  @ViewChild('myPalette', { static: true }) public myPaletteComponent: PaletteComponent;



  // initialize diagram / templates
  public initDiagram(): go.Diagram {

    const $ = go.GraphObject.make;
    const dia = $(go.Diagram, {
      'undoManager.isEnabled': true,
      model: $(go.GraphLinksModel,
        {
          linkToPortIdProperty: 'toPort',
          linkFromPortIdProperty: 'fromPort',
          linkKeyProperty: 'key' // IMPORTANT! must be defined for merges and data sync when using GraphLinksModel
        }
      )
    });

    dia.commandHandler.archetypeGroupData = { key: 'Group', isGroup: true };


    const makePort = (id: string, spot: go.Spot) => {
      return $(go.Shape, 'Circle',
        {
          opacity: .5,
          fill: 'gray', strokeWidth: 0, desiredSize: new go.Size(8, 8),
          portId: id, alignment: spot,
          fromLinkable: true, toLinkable: true
        }
      );
    }

    // define the Node template
    dia.nodeTemplate =
      $(go.Node, 'Spot',
        {
          contextMenu:
            $('ContextMenu',
              $('ContextMenuButton',
                $(go.TextBlock, 'Group'),
                { click: (e, obj) => { e.diagram.commandHandler.groupSelection(); } },
                new go.Binding('visible', '', (o) => {
                  return o.diagram.selection.count > 1;
                }).ofObject())
            )
        },
        $(go.Panel, 'Auto',
          $(go.Shape, 'RoundedRectangle', { stroke: null },
            new go.Binding('fill', 'color')
          ),
          $(go.TextBlock, { margin: 8 },
            new go.Binding('text', 'key'))
        ),
        // Ports
        makePort('t', go.Spot.TopCenter),
        makePort('l', go.Spot.Left),
        makePort('r', go.Spot.Right),
        makePort('b', go.Spot.BottomCenter)
      );


    dia.nodeTemplate.contextMenu =
      $("ContextMenu",
        $("ContextMenuButton",
          $(go.Panel, "Horizontal",
            FigureButton("Rectangle"), FigureButton("RoundedRectangle"), FigureButton("Ellipse"), FigureButton("Diamond")
          )
        ),

        $("ContextMenuButton", $(go.Panel, "Horizontal",
          $(go.TextBlock, 'edit task', { click: e => console.log('clicked') }))
        )
      );

    function FigureButton(fig, propname = 'figure') {

      return $(go.Shape,
        {
          width: 32, height: 32, scale: 0.5, fill: "lightgray", figure: fig,
          margin: 1, background: "transparent",
          mouseEnter: (e, shape) => { shape.fill = "dodgerblue"; },
          mouseLeave: (e, shape) => { shape.fill = "lightgray"; },
          click: ClickFunction(propname, fig), contextClick: ClickFunction(propname, fig)
        });
    }


    // PROPNAME is the name of the data property that should be set to the given VALUE.
    function ClickFunction(propname, value) {
      return (e, obj) => {
        e.handled = true;  // don't let the click bubble up
        e.diagram.skipsDiagramUpdate = false;

        e.diagram.model.commit((m) => {
          m.set(obj.part.adornedPart.data, propname, value);
        });
      };
    }


    return dia;
  }


  public diagramNodeData: Array<go.ObjectData> = [
    { key: 'Alpha', color: 'lightblue', arr: [1, 2] },
    { key: 'Beta', color: 'orange' },
    { key: 'Gamma', color: 'lightgreen' },
    { key: 'Delta', color: 'pink' }
  ];
  public diagramLinkData: Array<go.ObjectData> = [
    { key: -1, from: 'Alpha', to: 'Beta', fromPort: 'r', toPort: '1' },
    { key: -2, from: 'Alpha', to: 'Gamma', fromPort: 'b', toPort: 't' },
    { key: -3, from: 'Beta', to: 'Beta' },
    { key: -4, from: 'Gamma', to: 'Delta', fromPort: 'r', toPort: 'l' },
    { key: -5, from: 'Delta', to: 'Alpha', fromPort: 't', toPort: 'r' }
  ];
  public diagramDivClassName: string = 'myDiagramDiv';
  public diagramModelData = { prop: 'value' };
  public skipsDiagramUpdate = false;

  // When the diagram model changes, update app data to reflect those changes
  public diagramModelChange = function (changes: go.IncrementalData) {
    // when setting state here, be sure to set skipsDiagramUpdate: true since GoJS already has this update
    // (since this is a GoJS model changed listener event function)
    // this way, we don't log an unneeded transaction in the Diagram's undoManager history
    this.skipsDiagramUpdate = true;

    this.diagramNodeData = DataSyncService.syncNodeData(changes, this.diagramNodeData);
    this.diagramLinkData = DataSyncService.syncLinkData(changes, this.diagramLinkData);
    this.diagramModelData = DataSyncService.syncModelData(changes, this.diagramModelData);
  };



  public initPalette(): go.Palette {
    const $ = go.GraphObject.make;
    const palette = $(go.Palette);

    // define the Node template
    palette.nodeTemplate =
      $(go.Node, 'Auto',
        $(go.Shape, 'RoundedRectangle',
          {
            stroke: null
          },
          new go.Binding('fill', 'color')
        ),
        $(go.TextBlock, { margin: 8 },
          new go.Binding('text', 'key'))
      );

    palette.model = $(go.GraphLinksModel,
      {
        linkKeyProperty: 'key'  // IMPORTANT! must be defined for merges and data sync when using GraphLinksModel
      });

    return palette;
  }
  public paletteNodeData: Array<go.ObjectData> = [
    { key: 'PaletteNode1', color: 'firebrick' },
    { key: 'PaletteNode2', color: 'blueviolet' }
  ];
  public paletteLinkData: Array<go.ObjectData> = [
    { from: 'PaletteNode1', to: 'PaletteNode2' }
  ];
  public paletteModelData = { prop: 'val' };
  public paletteDivClassName = 'myPaletteDiv';
  public skipsPaletteUpdate = false;
  public paletteModelChange = function (changes: go.IncrementalData) {
    // when setting state here, be sure to set skipsPaletteUpdate: true since GoJS already has this update
    // (since this is a GoJS model changed listener event function)
    // this way, we don't log an unneeded transaction in the Palette's undoManager history
    this.skipsPaletteUpdate = true;

    this.paletteNodeData = DataSyncService.syncNodeData(changes, this.paletteNodeData);
    this.paletteLinkData = DataSyncService.syncLinkData(changes, this.paletteLinkData);
    this.paletteModelData = DataSyncService.syncModelData(changes, this.paletteModelData);
  };

  constructor(private cdr: ChangeDetectorRef) { }

  // Overview Component testing
  public oDivClassName = 'myOverviewDiv';
  public initOverview(): go.Overview {
    const $ = go.GraphObject.make;
    const overview = $(go.Overview);
    return overview;
  }
  public observedDiagram = null;

  // currently selected node; for inspector
  public selectedNode: go.Node | null = null;

  public ngAfterViewInit() {

    if (this.observedDiagram) return;
    this.observedDiagram = this.myDiagramComponent.diagram;
    this.cdr.detectChanges(); // IMPORTANT: without this, Angular will throw ExpressionChangedAfterItHasBeenCheckedError (dev mode only)

    const appComp: AppComponent = this;
    // listener for inspector
    this.myDiagramComponent.diagram.addDiagramListener('ChangedSelection', e => {
      if (e.diagram.selection.count === 0) {
        appComp.selectedNode = null;
      }
      const node = e.diagram.selection.first();
      if (node instanceof go.Node) {
        appComp.selectedNode = node;
      } else {
        appComp.selectedNode = null;
      }
    });

  } // end ngAfterViewInit


  public handleInspectorChange(newNodeData) {
    const key = newNodeData.key;
    // find the entry in nodeDataArray with this key, replace it with newNodeData
    let index = null;
    for (let i = 0; i < this.diagramNodeData.length; i++) {
      const entry = this.diagramNodeData[i];
      if (entry.key && entry.key === key) {
        index = i;
      }
    }

    if (index >= 0) {
      // here, we set skipsDiagramUpdate to false, since GoJS does not yet have this update
      this.skipsDiagramUpdate = false;
      this.diagramNodeData[index] = _.cloneDeep(newNodeData);
    }
  }


}


I hope you have noticed that you define a contextMenu in the node template, but then you immediately clobber it with a different context menu.

I can’t explain why it’s not working for you, because all of that diagram code is executing independent of any environment. It’s not depending on updating any state outside of the diagram/model in order to effect any changes in the model, because it is modifying the GoJS model directly.

Although I suppose it’s possible that an outer component is not getting the updated state and is then forcing the diagram’s (model’s) state back to how it had been before. But I do not know what else is going on in your app.

As far as calling external methods are concerned, usually you define an event in the diagram component that you can handle in your outer component. But it depends on exactly what you are trying to do.

No, I had not noticed. But it does not matter in the end, it is just the same result if I do this :

a.nodeTemplate =
      $(go.Node, 'Spot',
        {
          contextMenu:
            // $('ContextMenu',
            //   $('ContextMenuButton',
            //     $(go.TextBlock, 'Group'),
            //     { click: (e, obj) => { e.diagram.commandHandler.groupSelection(); } },
            //     new go.Binding('visible', '', (o) => {
            //       return o.diagram.selection.count > 1;
            //     }).ofObject())
            // )
            $("ContextMenu",
              $("ContextMenuButton",
                $(go.Panel, "Horizontal",
                  FigureButton("Rectangle"), FigureButton("RoundedRectangle"), FigureButton("Ellipse"), FigureButton("Diamond")
                )
              ),
              $("ContextMenuButton", $(go.Panel, "Horizontal",
                $(go.TextBlock, 'edit task', { click: e => console.log('clicked') }))
              )
            )

The code I posted is not mine, it is the the sample from gojs-angular-basic to which I’m just adding the context menu. So if the way I define the context menu is correct (and it seems so since the menu shows up and the click even is fired) then something is wrong with the angular wrapper.

Can you point me to an example of a working context menu with the angular wrapper ?

more info here. As a test, I started again from the gojs-angular-basic sample and I only changed this :

 public diagramNodeData: Array<go.ObjectData> = [
    { key: 'Alpha', color: 'lightblue', arr: [1, 2] },
    { key: 'Beta', color: 'orange', figure: 'Ellipse' },
    { key: 'Gamma', color: 'lightgreen' },
    { key: 'Delta', color: 'pink' }
  ];

As you can see I only added figure: 'Ellipse' and in the end, the shape displayed in the diagram is still a rectangle.

So maybe the problem I have with the context menu is in fact that the diagram cannot display shapes other than rectangles ? Is this not the right way to change the figure shape ?

ok, I figured it out.

The problem was that :

$(go.Panel, 'Auto',
          $(go.Shape, 'RoundedRectangle', { stroke: null },
            new go.Binding('fill', 'color')
          ),
          $(go.TextBlock, { margin: 8 },
            new go.Binding('text', 'key'))
        ),

The sample basically had the shape hardcoded to RoundedRectangle.

I changed this to

    $(go.Panel, 'Auto',
          $(go.Shape, new go.Binding('figure', 'figure'), { stroke: null },
            new go.Binding('fill', 'color')
          ),
          $(go.TextBlock, { margin: 8 },
            new go.Binding('text', 'key'))
        ),

and I added a figure property to the model. Now the click on the context menu updates the shape correctly

I think the angular sample should explain this a bit better as it is not so obvious when starting up.

Yes, the template determines entirely what appearance and behavior it has. As you have more properties on the data, or more uses for the same property, you might consider adding Bindings using those data properties.
https://gojs.net/latest/intro/dataBinding.html

The node template in the sample from which you took the code has a binding on Shape.figure.